From 92594488daae85c6c015d6b626d90c17638683f7 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 10:32:58 +0900 Subject: [PATCH 01/22] feat: stream workdir command output to consoles --- crates/protocol/src/lib.rs | 122 ++++- crates/protocol/src/typescript.rs | 10 +- crates/tools/src/bash.rs | 3 +- crates/tui/src/app.rs | 3 + crates/workdir/src/lib.rs | 14 + crates/workdir/src/local.rs | 515 ++++++++++++++++-- crates/workdir/src/operation.rs | 54 +- crates/worker-runtime/src/runtime.rs | 10 +- crates/worker/src/controller.rs | 112 +++- crates/worker/src/in_flight.rs | 144 ++++- crates/worker/src/internal_worker.rs | 5 +- crates/worker/tests/controller_test.rs | 108 +++- web/workspace/src/lib/generated/protocol.ts | 14 +- .../src/lib/workspace/console/model.test.ts | 117 ++++ .../src/lib/workspace/console/model.ts | 177 +++++- 15 files changed, 1332 insertions(+), 76 deletions(-) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index d44427cf..5d7932c9 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -547,6 +547,12 @@ pub enum Event { Status { status: WorkerStatus, }, + /// Bounded, provider-owned command telemetry for the live Console. This is + /// intentionally not a history entry and is reconstructed from + /// `Snapshot.in_flight.commands` after reconnect. + Command { + event: CommandEvent, + }, /// Reply to `Method::ListCompletions`. Delivered only to the /// requesting socket (not broadcast). `entries` is empty when no /// candidates match or when the requested kind has no resolver @@ -714,8 +720,71 @@ pub struct RewindSummary { pub tool_side_effect_warning: bool, } -/// Unfinished model output included in `Event::Snapshot` for clients that -/// attach while an LLM response is still streaming. +/// Live provider-owned command status. These values are operational Console +/// state only and are never appended to Worker history. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum CommandStatus { + Running, + Completed, + Failed, + TimedOut, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum CommandStream { + Stdout, + Stderr, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct CommandStreamSlice { + pub start_offset: u64, + pub end_offset: u64, + pub content: String, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +pub struct CommandSnapshot { + pub command_id: String, + pub tool_call_id: Option, + pub status: CommandStatus, + pub stdout: CommandStreamSlice, + pub stderr: CommandStreamSlice, + pub exit_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CommandEvent { + Started { + command_id: String, + tool_call_id: Option, + }, + Output { + command_id: String, + stream: CommandStream, + start_offset: u64, + end_offset: u64, + content: String, + }, + Terminal { + command_id: String, + status: CommandStatus, + exit_code: Option, + }, +} + +/// Unfinished model output and active command state included in +/// `Event::Snapshot` for clients that attach while work is still streaming. /// /// These blocks are presentation state only: they are reconstructed from the /// active Worker controller and must not be treated as committed assistant @@ -726,11 +795,13 @@ pub struct RewindSummary { pub struct InFlightSnapshot { #[serde(default, skip_serializing_if = "Vec::is_empty")] pub blocks: Vec, + #[serde(default, skip_serializing_if = "Vec::is_empty")] + pub commands: Vec, } impl InFlightSnapshot { pub fn is_empty(&self) -> bool { - self.blocks.is_empty() + self.blocks.is_empty() && self.commands.is_empty() } } @@ -1375,6 +1446,19 @@ mod tests { state: InFlightToolCallState::StreamingArgs, }, ], + commands: vec![CommandSnapshot { + command_id: "command-1".into(), + tool_call_id: Some("call_1".into()), + status: CommandStatus::Running, + stdout: CommandStreamSlice { + start_offset: 4, + end_offset: 8, + content: "tail".into(), + truncated: true, + }, + stderr: CommandStreamSlice::default(), + exit_code: None, + }], }, internal_workers: Vec::new(), }; @@ -1444,6 +1528,38 @@ mod tests { )); } + #[test] + fn event_command_output_roundtrip_preserves_stream_and_offsets() { + let event = Event::Command { + event: CommandEvent::Output { + command_id: "command-1".into(), + stream: CommandStream::Stderr, + start_offset: 8, + end_offset: 12, + content: "warn".into(), + }, + }; + let json = serde_json::to_string(&event).unwrap(); + let parsed: serde_json::Value = serde_json::from_str(&json).unwrap(); + assert_eq!(parsed["event"], "command"); + assert_eq!(parsed["data"]["event"]["kind"], "output"); + assert_eq!(parsed["data"]["event"]["stream"], "stderr"); + assert_eq!(parsed["data"]["event"]["start_offset"], 8); + assert_eq!(parsed["data"]["event"]["end_offset"], 12); + assert!(matches!( + serde_json::from_str::(&json).unwrap(), + Event::Command { + event: CommandEvent::Output { + command_id, + stream: CommandStream::Stderr, + start_offset: 8, + end_offset: 12, + content, + } + } if command_id == "command-1" && content == "warn" + )); + } + #[test] fn event_snapshot_legacy_without_status_defaults_to_idle() { let json = r#"{"event":"snapshot","data":{"entries":[],"greeting":{"worker_name":"test","cwd":"/tmp","provider":"anthropic","model":"claude","scope_summary":"","tools":[]}}}"#; diff --git a/crates/protocol/src/typescript.rs b/crates/protocol/src/typescript.rs index 54d9cecf..53245d7b 100644 --- a/crates/protocol/src/typescript.rs +++ b/crates/protocol/src/typescript.rs @@ -3,8 +3,9 @@ use std::path::PathBuf; use ts_rs::{Config, TS}; use crate::{ - Alert, AlertLevel, AlertSource, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, - InFlightBlock, InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, + Alert, AlertLevel, AlertSource, CommandEvent, CommandSnapshot, CommandStatus, CommandStream, + CommandStreamSlice, CompletionEntry, CompletionKind, ErrorCode, Event, Greeting, InFlightBlock, + InFlightSnapshot, InFlightToolCallState, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot, InvokeKind, MemoryWorkerEvent, Method, Permission, RewindSummary, RewindTarget, RewindTargetId, RunResult, ScopeRule, Segment, TurnResult, WorkerEvent, WorkerStatus, @@ -47,6 +48,11 @@ pub fn generated_protocol_types() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); diff --git a/crates/tools/src/bash.rs b/crates/tools/src/bash.rs index 0b7d57bb..50fe8625 100644 --- a/crates/tools/src/bash.rs +++ b/crates/tools/src/bash.rs @@ -43,7 +43,7 @@ impl Tool for BashTool { async fn execute( &self, input_json: &str, - _ctx: llm_engine::tool::ToolExecutionContext, + ctx: llm_engine::tool::ToolExecutionContext, ) -> Result { let params: BashParams = serde_json::from_str(input_json) .map_err(|error| ToolError::InvalidArgument(format!("invalid Bash input: {error}")))?; @@ -58,6 +58,7 @@ impl Tool for BashTool { command: params.command, timeout_secs, output_limit: INLINE_BYTE_BUDGET, + tool_call_id: Some(ctx.call_id), }) .await .map_err(crate::ToolsError::from)?; diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 473b7a91..32c532d7 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1322,6 +1322,9 @@ impl App { self.rewind_refresh_fence = false; self.set_worker_status(status); } + // Command telemetry is an operational Web Console surface. The + // TUI continues to render the final Bash ToolResult from history. + Event::Command { .. } => {} Event::Completions { kind, entries } => { // Apply only if the popup is still on the same // (kind, prefix) the request was issued for; an diff --git a/crates/workdir/src/lib.rs b/crates/workdir/src/lib.rs index fdd05175..0c226c57 100644 --- a/crates/workdir/src/lib.rs +++ b/crates/workdir/src/lib.rs @@ -16,6 +16,7 @@ use std::sync::Arc; use async_trait::async_trait; use serde::{Deserialize, Serialize}; +use tokio::sync::broadcast; pub use delegation::{ AppliedWorkdirDelegation, ReadOnlyWorkdirSession, WorkdirDelegation, @@ -192,6 +193,19 @@ pub trait WorkdirSession: std::fmt::Debug + Send + Sync { request: CommandOutputRequest, ) -> Result; async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError>; + + /// Subscribe to bounded provider-owned command telemetry. Implementations + /// that do not expose live command observation may keep the default. + fn subscribe_command_events(&self) -> Option> { + None + } + + /// Return the bounded current command state used to recover from a lagged + /// provider subscription without replaying command output into history. + fn command_snapshot(&self) -> Vec { + Vec::new() + } + /// Terminal, idempotent release of this Worker-bound operation session. async fn close(&self) -> Result<(), WorkdirError>; } diff --git a/crates/workdir/src/local.rs b/crates/workdir/src/local.rs index 51d3cc7a..c4eb28b4 100644 --- a/crates/workdir/src/local.rs +++ b/crates/workdir/src/local.rs @@ -14,21 +14,22 @@ use std::io::Write as _; use std::io::{Read as _, Seek as _, SeekFrom}; use std::path::{Path, PathBuf}; use std::process::Stdio; -use std::sync::Arc; use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex as StdMutex}; use std::time::Duration; use async_trait::async_trait; use manifest::{Permission, Scope, ScopeConfig, ScopeRule, SharedScope}; use sha2::{Digest, Sha256}; use tokio::process::Command; -use tokio::sync::{Mutex, Notify}; +use tokio::sync::{Mutex, Notify, broadcast, watch}; use tokio::task::JoinHandle; use crate::{ - CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, CommandStatus, EditRequest, - EditResult, GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, - ReadRequest, ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission, + CommandEvent, CommandHandle, CommandOutput, CommandOutputRequest, CommandRequest, + CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, EditRequest, EditResult, + GlobRequest, GlobResult, GrepRequest, GrepResult, ListRequest, ListResult, ReadRequest, + ReadResult, StatRequest, StatResult, Workdir, WorkdirDelegationPermission, WorkdirDelegationRequest, WorkdirError, WorkdirPath, WorkdirSession, WorkdirSessionCapabilities, WorkdirSessionCapability, WorkdirSessionHandle, WriteRequest, WriteResult, @@ -36,15 +37,146 @@ use crate::{ #[cfg(test)] use crate::{EntryKind, WriteOutcome}; +const COMMAND_EVENT_CHANNEL_CAPACITY: usize = 256; +const COMMAND_EVENT_CHUNK_BYTES: usize = 8 * 1024; +const COMMAND_SNAPSHOT_STREAM_BYTES: usize = 32 * 1024; + #[derive(Debug)] enum LocalCommand { Running { task: JoinHandle>, completion: Arc, + cancel: watch::Sender, }, Completed(CommandOutput), } +#[derive(Debug, Clone)] +struct CommandTelemetry { + inner: Arc, +} + +#[derive(Debug)] +struct CommandTelemetryInner { + snapshots: StdMutex>, + events: broadcast::Sender, +} + +impl CommandTelemetry { + fn new() -> Self { + let (events, _) = broadcast::channel(COMMAND_EVENT_CHANNEL_CAPACITY); + Self { + inner: Arc::new(CommandTelemetryInner { + snapshots: StdMutex::new(HashMap::new()), + events, + }), + } + } + + fn subscribe(&self) -> broadcast::Receiver { + self.inner.events.subscribe() + } + + fn snapshot(&self) -> Vec { + let mut snapshots = self + .inner + .snapshots + .lock() + .expect("command telemetry mutex poisoned") + .values() + .cloned() + .collect::>(); + snapshots.sort_by(|left, right| left.command_id.cmp(&right.command_id)); + snapshots + } + + fn started(&self, command_id: &str, tool_call_id: Option) { + self.inner + .snapshots + .lock() + .expect("command telemetry mutex poisoned") + .insert( + command_id.to_string(), + CommandSnapshot { + command_id: command_id.to_string(), + tool_call_id: tool_call_id.clone(), + status: CommandStatus::Running, + stdout: CommandStreamSlice::default(), + stderr: CommandStreamSlice::default(), + exit_code: None, + }, + ); + let _ = self.inner.events.send(CommandEvent::Started { + command_id: command_id.to_string(), + tool_call_id, + }); + } + + fn output(&self, command_id: &str, stream: CommandStream, start_offset: u64, bytes: &[u8]) { + if bytes.is_empty() { + return; + } + let end_offset = start_offset.saturating_add(bytes.len() as u64); + let content = String::from_utf8_lossy(bytes).into_owned(); + if let Some(snapshot) = self + .inner + .snapshots + .lock() + .expect("command telemetry mutex poisoned") + .get_mut(command_id) + { + let target = match stream { + CommandStream::Stdout => &mut snapshot.stdout, + CommandStream::Stderr => &mut snapshot.stderr, + }; + target.end_offset = end_offset; + target.content.push_str(&content); + if target.content.len() > COMMAND_SNAPSHOT_STREAM_BYTES { + let mut cut = target.content.len() - COMMAND_SNAPSHOT_STREAM_BYTES; + while cut < target.content.len() && !target.content.is_char_boundary(cut) { + cut += 1; + } + target.content.drain(..cut); + target.start_offset = end_offset.saturating_sub(target.content.len() as u64); + target.truncated = true; + } + } + let _ = self.inner.events.send(CommandEvent::Output { + command_id: command_id.to_string(), + stream, + start_offset, + end_offset, + content, + }); + } + + fn terminal(&self, command_id: &str, status: CommandStatus, exit_code: Option) { + if let Some(snapshot) = self + .inner + .snapshots + .lock() + .expect("command telemetry mutex poisoned") + .get_mut(command_id) + { + snapshot.status = status; + snapshot.exit_code = exit_code; + } + let _ = self.inner.events.send(CommandEvent::Terminal { + command_id: command_id.to_string(), + status, + exit_code, + }); + } + + fn remove(&self, command_id: &str) { + self.inner + .snapshots + .lock() + .expect("command telemetry mutex poisoned") + .remove(command_id); + } +} + #[derive(Debug)] struct ScopeAccess(Arc); @@ -69,6 +201,7 @@ struct LocalWorkdirSessionInner { close_lock: Mutex<()>, next_command_id: AtomicU64, commands: Mutex>, + command_telemetry: CommandTelemetry, } impl Drop for LocalWorkdirSessionInner { @@ -171,6 +304,7 @@ impl LocalWorkdirSession { close_lock: Mutex::new(()), next_command_id: AtomicU64::new(1), commands: Mutex::new(HashMap::new()), + command_telemetry: CommandTelemetry::new(), }), } } @@ -502,23 +636,35 @@ impl WorkdirSession for LocalWorkdirSession { async fn start_command(&self, request: CommandRequest) -> Result { self.ensure_capability(WorkdirSessionCapability::Command)?; + self.ensure_open()?; let id = self.inner.next_command_id.fetch_add(1, Ordering::Relaxed); let handle = CommandHandle(format!("command-{id}")); let cwd = self.inner.cwd.clone(); let completion = Arc::new(Notify::new()); let task_completion = Arc::clone(&completion); + let command_id = handle.0.clone(); + let telemetry = self.inner.command_telemetry.clone(); + let (cancel, cancel_rx) = watch::channel(false); let task = tokio::spawn(async move { - let output = run_command(cwd, request).await; + let output = run_command(cwd, request, command_id, telemetry, cancel_rx).await; task_completion.notify_one(); output }); let mut commands = self.inner.commands.lock().await; if let Err(error) = self.ensure_open() { + let _ = cancel.send(true); task.abort(); completion.notify_one(); return Err(error); } - commands.insert(handle.0.clone(), LocalCommand::Running { task, completion }); + commands.insert( + handle.0.clone(), + LocalCommand::Running { + task, + completion, + cancel, + }, + ); Ok(handle) } @@ -530,7 +676,13 @@ impl WorkdirSession for LocalWorkdirSession { .ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?; Ok(match command { LocalCommand::Running { task, .. } if !task.is_finished() => CommandStatus::Running, - LocalCommand::Running { .. } => CommandStatus::Completed, + LocalCommand::Running { .. } => self + .inner + .command_telemetry + .snapshot() + .into_iter() + .find(|snapshot| snapshot.command_id == handle.0) + .map_or(CommandStatus::Completed, |snapshot| snapshot.status), LocalCommand::Completed(output) => output.status, }) } @@ -584,36 +736,75 @@ impl WorkdirSession for LocalWorkdirSession { if !self.inner.closed.load(Ordering::Acquire) { commands.insert(request.handle.0, LocalCommand::Completed(output)); } + } else { + self.inner.command_telemetry.remove(&request.handle.0); } Ok(page) } async fn cancel_command(&self, handle: CommandHandle) -> Result<(), WorkdirError> { self.ensure_capability(WorkdirSessionCapability::Command)?; - let command = self - .inner - .commands - .lock() - .await - .remove(&handle.0) - .ok_or_else(|| WorkdirError::UnknownCommand(handle.0))?; - if let LocalCommand::Running { task, completion } = command { - task.abort(); - completion.notify_one(); + let cancel = { + let commands = self.inner.commands.lock().await; + let command = commands + .get(&handle.0) + .ok_or_else(|| WorkdirError::UnknownCommand(handle.0.clone()))?; + match command { + LocalCommand::Running { task, cancel, .. } if !task.is_finished() => { + Some(cancel.clone()) + } + _ => None, + } + }; + if let Some(cancel) = cancel { + let _ = cancel.send(true); } Ok(()) } + fn subscribe_command_events(&self) -> Option> { + self.inner + .capabilities + .supports(WorkdirSessionCapability::Command) + .then(|| self.inner.command_telemetry.subscribe()) + } + + fn command_snapshot(&self) -> Vec { + if self + .inner + .capabilities + .supports(WorkdirSessionCapability::Command) + { + self.inner.command_telemetry.snapshot() + } else { + Vec::new() + } + } + async fn close(&self) -> Result<(), WorkdirError> { let _close_guard = self.inner.close_lock.lock().await; if self.inner.closed.swap(true, Ordering::AcqRel) { return Ok(()); } - let mut commands = self.inner.commands.lock().await; - for (_, command) in commands.drain() { - if let LocalCommand::Running { task, completion } = command { - task.abort(); - completion.notify_one(); + let commands = { + let mut commands = self.inner.commands.lock().await; + commands + .drain() + .map(|(_, command)| command) + .collect::>() + }; + for command in commands { + match command { + LocalCommand::Running { + task, + completion, + cancel, + } => { + let _ = cancel.send(true); + let _ = task.await; + completion.notify_one(); + } + LocalCommand::Completed(_) => {} } } Ok(()) @@ -680,7 +871,13 @@ fn sanitize_error(error: WorkdirError, logical: &WorkdirPath) -> WorkdirError { } } -async fn run_command(cwd: PathBuf, request: CommandRequest) -> Result { +async fn run_command( + cwd: PathBuf, + request: CommandRequest, + command_id: String, + telemetry: CommandTelemetry, + mut cancel: watch::Receiver, +) -> Result { let stdout = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; let stderr = tempfile::NamedTempFile::new().map_err(|error| WorkdirError::io(&cwd, error))?; let stdout_path = stdout.into_temp_path(); @@ -690,7 +887,8 @@ async fn run_command(cwd: PathBuf, request: CommandRequest) -> Result Result { - let status = result.map_err(|error| WorkdirError::io(&cwd, error))?; - let (content, truncated) = - read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?; - return Ok(CommandOutput { - status: CommandStatus::Completed, - exit_code: status.code(), - timed_out: false, - content, - next_cursor: None, - truncated, - }); - } - Err(_) => { - let _ = child.kill().await; - true + Ok(child) => child, + Err(error) => { + telemetry.terminal(&command_id, CommandStatus::Failed, None); + return Err(WorkdirError::io(&cwd, error)); } }; + let mut stdout_reader = + std::fs::File::open(&stdout_path).map_err(|error| WorkdirError::io(&stdout_path, error))?; + let mut stderr_reader = + std::fs::File::open(&stderr_path).map_err(|error| WorkdirError::io(&stderr_path, error))?; + let mut stdout_offset = 0; + let mut stderr_offset = 0; + let mut timeout = Box::pin(tokio::time::sleep(Duration::from_secs( + request.timeout_secs.max(1), + ))); + let mut interval = tokio::time::interval(Duration::from_millis(50)); + interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + interval.tick().await; + + let (status, exit_code) = loop { + tokio::select! { + exit = child.wait() => { + let exit = exit.map_err(|error| WorkdirError::io(&cwd, error))?; + break ( + if exit.success() { CommandStatus::Completed } else { CommandStatus::Failed }, + exit.code(), + ); + } + _ = &mut timeout => { + let _ = child.start_kill(); + let exit_code = child.wait().await.ok().and_then(|status| status.code()); + break (CommandStatus::TimedOut, exit_code); + } + changed = cancel.changed() => { + if changed.is_err() || *cancel.borrow() { + let _ = child.start_kill(); + let exit_code = child.wait().await.ok().and_then(|status| status.code()); + break (CommandStatus::Cancelled, exit_code); + } + } + _ = interval.tick() => { + publish_available_output( + &mut stdout_reader, + &mut stdout_offset, + &telemetry, + &command_id, + CommandStream::Stdout, + &stdout_path, + )?; + publish_available_output( + &mut stderr_reader, + &mut stderr_offset, + &telemetry, + &command_id, + CommandStream::Stderr, + &stderr_path, + )?; + } + } + }; + + publish_available_output( + &mut stdout_reader, + &mut stdout_offset, + &telemetry, + &command_id, + CommandStream::Stdout, + &stdout_path, + )?; + publish_available_output( + &mut stderr_reader, + &mut stderr_offset, + &telemetry, + &command_id, + CommandStream::Stderr, + &stderr_path, + )?; + telemetry.terminal(&command_id, status, exit_code); + let (content, truncated) = read_command_output_files(&stdout_path, &stderr_path, request.output_limit.max(1))?; Ok(CommandOutput { - status: CommandStatus::Failed, - exit_code: None, - timed_out, + status, + exit_code, + timed_out: status == CommandStatus::TimedOut, content, next_cursor: None, truncated, }) } +fn publish_available_output( + file: &mut std::fs::File, + offset: &mut u64, + telemetry: &CommandTelemetry, + command_id: &str, + stream: CommandStream, + path: &Path, +) -> Result<(), WorkdirError> { + file.seek(SeekFrom::Start(*offset)) + .map_err(|error| WorkdirError::io(path, error))?; + loop { + let mut buffer = vec![0; COMMAND_EVENT_CHUNK_BYTES]; + let read = file + .read(&mut buffer) + .map_err(|error| WorkdirError::io(path, error))?; + if read == 0 { + return Ok(()); + } + buffer.truncate(read); + telemetry.output(command_id, stream, *offset, &buffer); + *offset = offset.saturating_add(read as u64); + if read < COMMAND_EVENT_CHUNK_BYTES { + return Ok(()); + } + } +} + fn read_command_output_files( stdout_path: &Path, stderr_path: &Path, @@ -1024,6 +1303,7 @@ mod tests { command: "sleep 30".to_owned(), timeout_secs: 60, output_limit: 1024, + tool_call_id: None, }, ) .await @@ -1549,6 +1829,7 @@ mod tests { command: "pwd && printf provider-command".into(), timeout_secs: 5, output_limit: 4096, + tool_call_id: None, }, ) .await @@ -1583,6 +1864,7 @@ mod tests { command: "printf 'aéz'".into(), timeout_secs: 5, output_limit: 1024, + tool_call_id: None, }, ) .await @@ -1620,6 +1902,130 @@ mod tests { )); } + #[tokio::test] + async fn provider_streams_bounded_command_lifecycle_and_distinct_output() { + let dir = TempDir::new().unwrap(); + let workdir = make_fs(&dir); + let mut events = WorkdirSession::subscribe_command_events(&workdir) + .expect("local command observation must be available"); + let handle = WorkdirSession::start_command( + &workdir, + CommandRequest { + command: "printf ready; printf warning >&2; sleep 0.2; printf done".into(), + timeout_secs: 5, + output_limit: 1024, + tool_call_id: Some("tool-7".into()), + }, + ) + .await + .unwrap(); + + let mut stdout = String::new(); + let mut stderr = String::new(); + let mut terminal = None; + while terminal.is_none() { + let event = tokio::time::timeout(Duration::from_secs(2), events.recv()) + .await + .expect("command telemetry should not stall") + .unwrap(); + match event { + CommandEvent::Started { + command_id, + tool_call_id, + } => { + assert_eq!(command_id, handle.0); + assert_eq!(tool_call_id.as_deref(), Some("tool-7")); + } + CommandEvent::Output { + command_id, + stream, + content, + .. + } => { + assert_eq!(command_id, handle.0); + match stream { + CommandStream::Stdout => stdout.push_str(&content), + CommandStream::Stderr => stderr.push_str(&content), + } + } + CommandEvent::Terminal { + command_id, + status, + exit_code, + } => { + assert_eq!(command_id, handle.0); + terminal = Some((status, exit_code)); + } + } + } + assert_eq!(terminal, Some((CommandStatus::Completed, Some(0)))); + assert_eq!(stdout, "readydone"); + assert_eq!(stderr, "warning"); + let snapshot = WorkdirSession::command_snapshot(&workdir); + assert_eq!(snapshot.len(), 1); + assert_eq!(snapshot[0].status, CommandStatus::Completed); + assert_eq!(snapshot[0].stdout.content, "readydone"); + assert_eq!(snapshot[0].stderr.content, "warning"); + + let output = WorkdirSession::command_output( + &workdir, + CommandOutputRequest { + handle, + cursor: 0, + limit: 1024, + wait: true, + }, + ) + .await + .unwrap(); + assert_eq!(output.status, CommandStatus::Completed); + assert!(WorkdirSession::command_snapshot(&workdir).is_empty()); + } + + #[tokio::test] + async fn provider_distinguishes_timed_out_terminal_state() { + let dir = TempDir::new().unwrap(); + let workdir = make_fs(&dir); + let mut events = WorkdirSession::subscribe_command_events(&workdir).unwrap(); + let handle = WorkdirSession::start_command( + &workdir, + CommandRequest { + command: "sleep 30".into(), + timeout_secs: 1, + output_limit: 1024, + tool_call_id: None, + }, + ) + .await + .unwrap(); + let output = WorkdirSession::command_output( + &workdir, + CommandOutputRequest { + handle: handle.clone(), + cursor: 0, + limit: 1024, + wait: true, + }, + ) + .await + .unwrap(); + assert_eq!(output.status, CommandStatus::TimedOut); + assert!(output.timed_out); + + let mut terminal = None; + while let Ok(event) = events.try_recv() { + if let CommandEvent::Terminal { + command_id, + status, + exit_code, + } = event + { + terminal = Some((command_id, status, exit_code)); + } + } + assert_eq!(terminal, Some((handle.0, CommandStatus::TimedOut, None))); + } + #[tokio::test] async fn provider_cancels_active_command() { let dir = TempDir::new().unwrap(); @@ -1630,6 +2036,7 @@ mod tests { command: "sleep 30".into(), timeout_secs: 60, output_limit: 1024, + tool_call_id: None, }, ) .await @@ -1658,12 +2065,12 @@ mod tests { WorkdirSession::cancel_command(&workdir, handle.clone()) .await .unwrap(); - let waiter_error = tokio::time::timeout(Duration::from_secs(1), waiter) + let output = tokio::time::timeout(Duration::from_secs(1), waiter) .await .expect("cancel should wake command output waiters") .unwrap() - .unwrap_err(); - assert!(matches!(waiter_error, WorkdirError::UnknownCommand(_))); + .unwrap(); + assert_eq!(output.status, CommandStatus::Cancelled); assert!(matches!( WorkdirSession::command_status(&workdir, handle).await, Err(WorkdirError::UnknownCommand(_)) diff --git a/crates/workdir/src/operation.rs b/crates/workdir/src/operation.rs index e1e0d15e..54a1f282 100644 --- a/crates/workdir/src/operation.rs +++ b/crates/workdir/src/operation.rs @@ -9,6 +9,11 @@ pub struct CommandRequest { pub command: String, pub timeout_secs: u64, pub output_limit: usize, + /// Optional caller-owned correlation id. Bash supplies its tool-call id so + /// user-facing command telemetry can update the corresponding Console row + /// without exposing provider/session handles. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_call_id: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -24,8 +29,55 @@ pub struct CommandOutputRequest { pub enum CommandStatus { Running, Completed, - Cancelled, Failed, + TimedOut, + Cancelled, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum CommandStream { + Stdout, + Stderr, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] +pub struct CommandStreamSlice { + pub start_offset: u64, + pub end_offset: u64, + pub content: String, + pub truncated: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct CommandSnapshot { + pub command_id: String, + pub tool_call_id: Option, + pub status: CommandStatus, + pub stdout: CommandStreamSlice, + pub stderr: CommandStreamSlice, + pub exit_code: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum CommandEvent { + Started { + command_id: String, + tool_call_id: Option, + }, + Output { + command_id: String, + stream: CommandStream, + start_offset: u64, + end_offset: u64, + content: String, + }, + Terminal { + command_id: String, + status: CommandStatus, + exit_code: Option, + }, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/worker-runtime/src/runtime.rs b/crates/worker-runtime/src/runtime.rs index b97c5b58..525ea88c 100644 --- a/crates/worker-runtime/src/runtime.rs +++ b/crates/worker-runtime/src/runtime.rs @@ -1430,7 +1430,10 @@ impl Runtime { context_tokens: 0, }, status: protocol::WorkerStatus::Idle, - in_flight: protocol::InFlightSnapshot { blocks: Vec::new() }, + in_flight: protocol::InFlightSnapshot { + blocks: Vec::new(), + commands: Vec::new(), + }, internal_workers: Vec::new(), }) } @@ -3765,7 +3768,10 @@ mod tests { context_tokens: 64, }, status: protocol::WorkerStatus::Running, - in_flight: protocol::InFlightSnapshot { blocks: Vec::new() }, + in_flight: protocol::InFlightSnapshot { + blocks: Vec::new(), + commands: Vec::new(), + }, internal_workers: Vec::new(), }, ); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 4db07122..7b5ad021 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -28,8 +28,14 @@ use crate::worker::{ WorkerRunResult, }; use protocol::{ - AlertLevel, AlertSource, ErrorCode, Event, Method, RewindTargetId, RunResult, Segment, - TurnResult, WorkerStatus, + AlertLevel, AlertSource, CommandEvent as ProtocolCommandEvent, + CommandSnapshot as ProtocolCommandSnapshot, CommandStatus as ProtocolCommandStatus, + CommandStream as ProtocolCommandStream, CommandStreamSlice as ProtocolCommandStreamSlice, + ErrorCode, Event, Method, RewindTargetId, RunResult, Segment, TurnResult, WorkerStatus, +}; +use workdir::{ + CommandEvent as WorkdirCommandEvent, CommandSnapshot as WorkdirCommandSnapshot, + CommandStatus as WorkdirCommandStatus, CommandStream as WorkdirCommandStream, WorkdirSession, }; // --------------------------------------------------------------------------- @@ -424,6 +430,9 @@ impl WorkerController { Some(method_tx.downgrade()), ) .await?; + if let Some(session) = fs_for_view.as_ref() { + wire_workdir_command_events(session, &in_flight); + } // Intake role Workers self-terminate only after a successful // TicketIntakeReady turn has fully settled back to Idle. The request @@ -498,6 +507,105 @@ impl WorkerController { } } +pub(crate) fn wire_workdir_command_events( + session: &Arc, + in_flight: &InFlightEvents, +) { + in_flight.replace_command_snapshot( + session + .command_snapshot() + .into_iter() + .map(protocol_command_snapshot) + .collect(), + ); + let Some(mut events) = session.subscribe_command_events() else { + return; + }; + let in_flight = in_flight.clone(); + tokio::spawn(async move { + loop { + match events.recv().await { + Ok(event) => in_flight.publish_command_event(protocol_command_event(event)), + Err(broadcast::error::RecvError::Lagged(_)) => { + // Never retain stale command output after a provider-local + // observer lag. The next chunk reconstructs a bounded tail + // with its absolute offset and marks the gap truncated. + in_flight.replace_command_snapshot(Vec::new()); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); +} + +fn protocol_command_snapshot(snapshot: WorkdirCommandSnapshot) -> ProtocolCommandSnapshot { + ProtocolCommandSnapshot { + command_id: snapshot.command_id, + tool_call_id: snapshot.tool_call_id, + status: protocol_command_status(snapshot.status), + stdout: ProtocolCommandStreamSlice { + start_offset: snapshot.stdout.start_offset, + end_offset: snapshot.stdout.end_offset, + content: snapshot.stdout.content, + truncated: snapshot.stdout.truncated, + }, + stderr: ProtocolCommandStreamSlice { + start_offset: snapshot.stderr.start_offset, + end_offset: snapshot.stderr.end_offset, + content: snapshot.stderr.content, + truncated: snapshot.stderr.truncated, + }, + exit_code: snapshot.exit_code, + } +} + +fn protocol_command_event(event: WorkdirCommandEvent) -> ProtocolCommandEvent { + match event { + WorkdirCommandEvent::Started { + command_id, + tool_call_id, + } => ProtocolCommandEvent::Started { + command_id, + tool_call_id, + }, + WorkdirCommandEvent::Output { + command_id, + stream, + start_offset, + end_offset, + content, + } => ProtocolCommandEvent::Output { + command_id, + stream: match stream { + WorkdirCommandStream::Stdout => ProtocolCommandStream::Stdout, + WorkdirCommandStream::Stderr => ProtocolCommandStream::Stderr, + }, + start_offset, + end_offset, + content, + }, + WorkdirCommandEvent::Terminal { + command_id, + status, + exit_code, + } => ProtocolCommandEvent::Terminal { + command_id, + status: protocol_command_status(status), + exit_code, + }, + } +} + +fn protocol_command_status(status: WorkdirCommandStatus) -> ProtocolCommandStatus { + match status { + WorkdirCommandStatus::Running => ProtocolCommandStatus::Running, + WorkdirCommandStatus::Completed => ProtocolCommandStatus::Completed, + WorkdirCommandStatus::Failed => ProtocolCommandStatus::Failed, + WorkdirCommandStatus::TimedOut => ProtocolCommandStatus::TimedOut, + WorkdirCommandStatus::Cancelled => ProtocolCommandStatus::Cancelled, + } +} + /// Wire the per-event broadcast bridges on the Worker's Engine. Each callback /// re-publishes a worker-level signal as a `protocol::Event` on `event_tx` /// so subscribers (TUI, socket clients) get a single typed stream. diff --git a/crates/worker/src/in_flight.rs b/crates/worker/src/in_flight.rs index 6a08d881..cbc819df 100644 --- a/crates/worker/src/in_flight.rs +++ b/crates/worker/src/in_flight.rs @@ -1,9 +1,14 @@ use std::sync::{Arc, Mutex, MutexGuard}; -use protocol::{Event, InFlightBlock, InFlightSnapshot, InFlightToolCallState}; +use protocol::{ + CommandEvent, CommandSnapshot, CommandStatus, CommandStream, CommandStreamSlice, Event, + InFlightBlock, InFlightSnapshot, InFlightToolCallState, +}; use session_store::{LoggedContentPart, LoggedItem}; use tokio::sync::broadcast; +const COMMAND_SNAPSHOT_STREAM_BYTES: usize = 32 * 1024; + #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub struct InFlightBlockId(u64); @@ -17,6 +22,7 @@ pub struct InFlightEvents { pub(crate) struct InFlightInner { next_block_id: u64, blocks: Vec, + commands: Vec, } #[derive(Debug, Clone)] @@ -46,6 +52,7 @@ impl InFlightEvents { inner: Arc::new(Mutex::new(InFlightInner { next_block_id: 1, blocks: Vec::new(), + commands: Vec::new(), })), event_tx, } @@ -201,6 +208,15 @@ impl InFlightEvents { f() } + pub(crate) fn publish_command_event(&self, event: CommandEvent) { + self.lock().apply_command_event(&event); + let _ = self.event_tx.send(Event::Command { event }); + } + + pub(crate) fn replace_command_snapshot(&self, commands: Vec) { + self.lock().commands = commands; + } + pub(crate) fn clear(&self) { let mut inner = self.lock(); inner.clear(); @@ -224,6 +240,82 @@ impl InFlightInner { .find(|block| block.block_id() == block_id) } + fn apply_command_event(&mut self, event: &CommandEvent) { + match event { + CommandEvent::Started { + command_id, + tool_call_id, + } => { + self.commands + .retain(|command| command.command_id != *command_id); + self.commands.push(CommandSnapshot { + command_id: command_id.clone(), + tool_call_id: tool_call_id.clone(), + status: CommandStatus::Running, + stdout: CommandStreamSlice::default(), + stderr: CommandStreamSlice::default(), + exit_code: None, + }); + } + CommandEvent::Output { + command_id, + stream, + start_offset, + end_offset, + content, + } => { + let command = match self + .commands + .iter_mut() + .find(|command| command.command_id == *command_id) + { + Some(command) => command, + None => { + self.commands.push(CommandSnapshot { + command_id: command_id.clone(), + tool_call_id: None, + status: CommandStatus::Running, + stdout: CommandStreamSlice::default(), + stderr: CommandStreamSlice::default(), + exit_code: None, + }); + self.commands.last_mut().expect("command was inserted") + } + }; + let target = match stream { + CommandStream::Stdout => &mut command.stdout, + CommandStream::Stderr => &mut command.stderr, + }; + if target.end_offset != *start_offset { + target.content.clear(); + target.start_offset = *start_offset; + target.truncated = *start_offset > 0; + } + target.content.push_str(content); + target.end_offset = *end_offset; + if target.content.len() > COMMAND_SNAPSHOT_STREAM_BYTES { + let mut cut = target.content.len() - COMMAND_SNAPSHOT_STREAM_BYTES; + while cut < target.content.len() && !target.content.is_char_boundary(cut) { + cut += 1; + } + target.content.drain(..cut); + target.start_offset = target + .end_offset + .saturating_sub(target.content.len() as u64); + target.truncated = true; + } + } + CommandEvent::Terminal { command_id, .. } => { + // Terminal state is delivered as a live protocol event. It is + // no longer in-flight snapshot state, and removing it here + // also prevents queued output from an aborted turn from + // surviving the subsequent terminal event after `clear()`. + self.commands + .retain(|command| command.command_id != *command_id); + } + } + } + fn clear_for_committed_item(&mut self, item: &LoggedItem) { match item { LoggedItem::Message { role, content } @@ -273,14 +365,16 @@ impl InFlightInner { .iter() .filter_map(TrackedBlock::to_snapshot_block) .collect(), + commands: self.commands.clone(), } } fn clear(&mut self) -> bool { - if self.blocks.is_empty() { + if self.blocks.is_empty() && self.commands.is_empty() { false } else { self.blocks.clear(); + self.commands.clear(); true } } @@ -583,6 +677,52 @@ mod tests { ); } + #[test] + fn command_events_are_bounded_and_recoverable_from_snapshot() { + let (event_tx, _) = broadcast::channel(16); + let mut rx = event_tx.subscribe(); + let in_flight = InFlightEvents::new(event_tx); + in_flight.publish_command_event(CommandEvent::Started { + command_id: "command-1".into(), + tool_call_id: Some("tool-1".into()), + }); + in_flight.publish_command_event(CommandEvent::Output { + command_id: "command-1".into(), + stream: CommandStream::Stdout, + start_offset: 0, + end_offset: 5, + content: "ready".into(), + }); + + let guard = in_flight.snapshot_guard(); + let snapshot = snapshot_from_guard(&guard); + assert_eq!(snapshot.commands.len(), 1); + assert_eq!(snapshot.commands[0].tool_call_id.as_deref(), Some("tool-1")); + assert_eq!(snapshot.commands[0].stdout.content, "ready"); + assert_eq!(snapshot.commands[0].status, CommandStatus::Running); + drop(guard); + assert!(matches!( + rx.try_recv().unwrap(), + Event::Command { + event: CommandEvent::Started { .. } + } + )); + assert!(matches!( + rx.try_recv().unwrap(), + Event::Command { + event: CommandEvent::Output { .. } + } + )); + + in_flight.publish_command_event(CommandEvent::Terminal { + command_id: "command-1".into(), + status: CommandStatus::TimedOut, + exit_code: None, + }); + let guard = in_flight.snapshot_guard(); + assert!(snapshot_from_guard(&guard).commands.is_empty()); + } + #[test] fn clear_discards_uncommitted_blocks_without_protocol_event() { let (event_tx, _) = broadcast::channel(16); diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 0a21d0d2..5577da78 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -17,7 +17,7 @@ use session_store::{LogEntry, SegmentId, SessionId, Store, StoreError, TraceEntr use tokio::sync::broadcast; use uuid::Uuid; -use crate::controller::wire_event_bridges_on_engine; +use crate::controller::{wire_event_bridges_on_engine, wire_workdir_command_events}; use crate::feature::FeatureRegistryBuilder; use crate::in_flight::{InFlightEvents, snapshot_from_guard}; use crate::ipc::alerter::Alerter; @@ -555,6 +555,9 @@ pub(crate) async fn prepare_internal_worker_session( spawn_internal_log_event_bridge(sink.clone(), event_tx.clone()); let alerter = Alerter::new(event_tx.clone()); let in_flight = InFlightEvents::new(event_tx.clone()); + if let Some(session) = worker.workdir_session() { + wire_workdir_command_events(session, &in_flight); + } let actor_in_flight = in_flight.clone(); worker.attach_alerter(alerter.clone()); worker.attach_event_tx(event_tx.clone()); diff --git a/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index bbe43ba5..41391433 100644 --- a/crates/worker/tests/controller_test.rs +++ b/crates/worker/tests/controller_test.rs @@ -12,8 +12,8 @@ use llm_engine::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use session_store::{CombinedStore, FsWorkerStore}; use session_store::{FsStore, LogEntry}; use workdir::{ - CommandRequest, LocalWorkdirSession, Workdir, WorkdirError, WorkdirSessionCapabilities, - WorkdirSessionHandle, + CommandOutputRequest, CommandRequest, LocalWorkdirSession, Workdir, WorkdirError, + WorkdirSessionCapabilities, WorkdirSessionHandle, }; use worker::{ @@ -232,6 +232,7 @@ async fn shutdown_closes_bound_workdir_session() { command: "sleep 30".to_owned(), timeout_secs: 60, output_limit: 1024, + tool_call_id: None, }) .await .unwrap(); @@ -253,6 +254,108 @@ async fn shutdown_closes_bound_workdir_session() { )); } +#[tokio::test] +async fn controller_projects_workdir_command_events_and_snapshot_state() { + let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await; + let session: WorkdirSessionHandle = Arc::new(LocalWorkdirSession::materialized_bound( + Workdir::new("controller-command-observation-workdir"), + pwd.clone(), + pwd, + worker.scope().clone(), + WorkdirSessionCapabilities::ALL, + )); + worker.bind_workdir_session(Some(Arc::clone(&session))); + let handle = spawn_controller(worker).await; + let mut events = handle.subscribe(); + + let command = session + .start_command(CommandRequest { + command: "printf ready; sleep 0.3; printf done".to_owned(), + timeout_secs: 5, + output_limit: 1024, + tool_call_id: Some("tool-command-1".into()), + }) + .await + .unwrap(); + + let mut saw_started = false; + let mut saw_output = false; + while !saw_output { + let event = tokio::time::timeout(std::time::Duration::from_secs(2), events.recv()) + .await + .expect("command event should arrive") + .unwrap(); + match event { + Event::Command { + event: + protocol::CommandEvent::Started { + command_id, + tool_call_id, + }, + } => { + assert_eq!(command_id, command.0); + assert_eq!(tool_call_id.as_deref(), Some("tool-command-1")); + saw_started = true; + } + Event::Command { + event: + protocol::CommandEvent::Output { + command_id, + stream: protocol::CommandStream::Stdout, + content, + .. + }, + } if command_id == command.0 && content.contains("ready") => saw_output = true, + _ => {} + } + } + assert!(saw_started); + + let Event::Snapshot { in_flight, .. } = handle.snapshot_event() else { + panic!("worker snapshot expected"); + }; + assert_eq!(in_flight.commands.len(), 1); + assert_eq!(in_flight.commands[0].command_id, command.0); + assert_eq!(in_flight.commands[0].stdout.content, "ready"); + assert_eq!( + in_flight.commands[0].status, + protocol::CommandStatus::Running + ); + + let saw_terminal = drain_until(&mut events, std::time::Duration::from_secs(2), |event| { + matches!( + event, + Event::Command { + event: protocol::CommandEvent::Terminal { + command_id, + status: protocol::CommandStatus::Completed, + exit_code: Some(0), + } + } if command_id == &command.0 + ) + }) + .await; + assert!(saw_terminal, "completed command event should arrive"); + + let output = session + .command_output(CommandOutputRequest { + handle: command, + cursor: 0, + limit: 1024, + wait: true, + }) + .await + .unwrap(); + assert_eq!(output.status, workdir::CommandStatus::Completed); + let (entries, _) = handle.sink.subscribe_with_snapshot(); + let durable_history = serde_json::to_string(&entries).unwrap(); + assert!( + !durable_history.contains("ready") && !durable_history.contains("done"), + "operational command chunks must not be appended to Worker history: {durable_history}" + ); + handle.send(Method::Shutdown).await.unwrap(); +} + #[tokio::test] async fn controller_startup_failure_closes_bound_workdir_session() { let (mut worker, pwd) = make_worker_with_pwd(MockClient::new(simple_text_events())).await; @@ -279,6 +382,7 @@ async fn controller_startup_failure_closes_bound_workdir_session() { command: "printf unreachable".to_owned(), timeout_secs: 5, output_limit: 1024, + tool_call_id: None, }) .await, Err(WorkdirError::Unavailable(_)) diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 471f4dab..6a85bd7e 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -22,6 +22,16 @@ export type Permission = "read" | "write"; export type InFlightToolCallState = "pending" | "streaming_args" | "done"; +export type CommandStatus = "running" | "completed" | "failed" | "timed_out" | "cancelled"; + +export type CommandStream = "stdout" | "stderr"; + +export type CommandStreamSlice = { start_offset: number, end_offset: number, content: string, truncated: boolean, }; + +export type CommandSnapshot = { command_id: string, tool_call_id: string | null, status: CommandStatus, stdout: CommandStreamSlice, stderr: CommandStreamSlice, exit_code: number | null, }; + +export type CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, }; + export type ScopeRule = { /** * Target path. Must be absolute by the time a `Scope` is built from @@ -51,7 +61,7 @@ export type RewindSummary = { truncated_to_entries: number, discarded_entries: n export type InFlightBlock = { "kind": "text", text: string, finished?: boolean, } | { "kind": "thinking", text: string, finished?: boolean, } | { "kind": "tool_call", id: string, name: string, args: string, state?: InFlightToolCallState, }; -export type InFlightSnapshot = { blocks?: Array, }; +export type InFlightSnapshot = { blocks?: Array, commands?: Array, }; export type InternalWorkerKind = "sub_worker"; @@ -178,4 +188,4 @@ in_flight?: InFlightSnapshot, * Parent-owned Internal Worker sessions visible to this client. * Service-private Internal Workers are deliberately excluded. */ -internal_workers?: Array, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array, } } | { "event": "rewind_applied", "data": { entries: Array, input: Array, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" }; +internal_workers?: Array, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "command", "data": { event: CommandEvent, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array, } } | { "event": "rewind_applied", "data": { entries: Array, input: Array, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" }; diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 041fdbfb..b10c087c 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -334,6 +334,123 @@ Deno.test("projectConsole groups tool call lifecycle into one Call block", () => ); }); +Deno.test("projectConsole streams distinct Bash stdout and stderr through terminal status", () => { + const projection = projectConsole([ + { + eventId: "command-tool", + event: { + event: "tool_call_done", + data: { + id: "bash-stream", + name: "Bash", + arguments: JSON.stringify({ command: "long-command" }), + }, + } satisfies Event, + }, + { + eventId: "command-started", + event: { + event: "command", + data: { + event: { + kind: "started", + command_id: "command-1", + tool_call_id: "bash-stream", + }, + }, + } satisfies Event, + }, + { + eventId: "command-stdout", + event: { + event: "command", + data: { + event: { + kind: "output", + command_id: "command-1", + stream: "stdout", + start_offset: 0, + end_offset: 6, + content: "ready\n", + }, + }, + } satisfies Event, + }, + { + eventId: "command-stderr", + event: { + event: "command", + data: { + event: { + kind: "output", + command_id: "command-1", + stream: "stderr", + start_offset: 0, + end_offset: 5, + content: "warn\n", + }, + }, + } satisfies Event, + }, + { + eventId: "command-terminal", + event: { + event: "command", + data: { + event: { + kind: "terminal", + command_id: "command-1", + status: "failed", + exit_code: 7, + }, + }, + } satisfies Event, + }, + ]); + + const [line] = projection.lines.filter((line) => line.kind === "tool"); + assert(line.body.includes("Bash — failed (exit 7)"), line.body); + assert(line.body.includes("stdout:\nready\n"), line.body); + assert(line.body.includes("stderr:\nwarn\n"), line.body); + assertEquals(line.streaming, false); + assertEquals(line.error, true); +}); + +Deno.test("snapshot restores bounded in-flight Bash command output", () => { + const snapshot = snapshotEvent("/repo"); + if (snapshot.event !== "snapshot") throw new Error("snapshot fixture expected"); + snapshot.data.status = "running"; + snapshot.data.in_flight = { + blocks: [{ + kind: "tool_call", + id: "bash-snapshot", + name: "Bash", + args: JSON.stringify({ command: "slow" }), + state: "done", + }], + commands: [{ + command_id: "command-2", + tool_call_id: "bash-snapshot", + status: "running", + stdout: { + start_offset: 1024, + end_offset: 1031, + content: "tail\n", + truncated: true, + }, + stderr: { start_offset: 0, end_offset: 0, content: "", truncated: false }, + exit_code: null, + }], + }; + + const projection = projectConsole([{ eventId: "snapshot-command", event: snapshot }]); + const [line] = projection.lines.filter((line) => line.kind === "tool"); + assert(line.body.includes("Bash — running…"), line.body); + assert(line.body.includes("[stdout tail; earlier output omitted]"), line.body); + assert(line.body.includes("stdout:\ntail\n"), line.body); + assertEquals(line.streaming, true); +}); + Deno.test("projectConsole caps default tool request and result previews", () => { const projection = projectConsole([ { diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 2e632fff..15dcbaba 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -1,5 +1,8 @@ import type { Alert, + CommandEvent, + CommandSnapshot, + CommandStreamSlice, Event as ProtocolEvent, InFlightBlock, InFlightToolCallState, @@ -42,6 +45,7 @@ type ToolCallView = { output?: string | null; isError?: boolean; cwd?: string | null; + command?: CommandSnapshot; }; export type ConsoleDiffLine = { @@ -239,6 +243,126 @@ function appendSnapshotInFlightLines( }); } +const COMMAND_STREAM_DISPLAY_BYTES = 32 * 1024; + +function appendSnapshotCommands( + projection: ConsoleProjection, + commands: CommandSnapshot[], + eventId: string, +): void { + commands.forEach((command) => upsertCommandSnapshot(projection, eventId, command)); +} + +function upsertCommandSnapshot( + projection: ConsoleProjection, + eventId: string, + command: CommandSnapshot, +): void { + const toolCallId = command.tool_call_id ?? `command:${command.command_id}`; + const existingIndex = findToolCallLineIndex(projection, toolCallId); + const existing = existingIndex >= 0 + ? projection.lines[existingIndex].toolCall + : undefined; + upsertToolCall(projection, eventId, toolCallId, { + name: existing?.name ?? "Bash", + state: existing?.state ?? "running", + command, + }); +} + +function applyCommandEvent( + projection: ConsoleProjection, + eventId: string, + event: CommandEvent, +): void { + if (event.kind === "started") { + upsertCommandSnapshot(projection, eventId, { + command_id: event.command_id, + tool_call_id: event.tool_call_id, + status: "running", + stdout: emptyCommandStream(), + stderr: emptyCommandStream(), + exit_code: null, + }); + return; + } + + const index = projection.lines.findIndex((line) => + line.toolCall?.command?.command_id === event.command_id + ); + if (index < 0) { + if (event.kind === "output") { + const stream = commandStreamFromEvent(event); + upsertCommandSnapshot(projection, eventId, { + command_id: event.command_id, + tool_call_id: null, + status: "running", + stdout: event.stream === "stdout" ? stream : emptyCommandStream(), + stderr: event.stream === "stderr" ? stream : emptyCommandStream(), + exit_code: null, + }); + } + return; + } + + const existing = projection.lines[index].toolCall!.command!; + if (event.kind === "terminal") { + upsertCommandSnapshot(projection, eventId, { + ...existing, + status: event.status, + exit_code: event.exit_code, + }); + return; + } + const updatedStream = appendCommandStream( + event.stream === "stdout" ? existing.stdout : existing.stderr, + event.start_offset, + event.end_offset, + event.content, + ); + upsertCommandSnapshot(projection, eventId, { + ...existing, + stdout: event.stream === "stdout" ? updatedStream : existing.stdout, + stderr: event.stream === "stderr" ? updatedStream : existing.stderr, + }); +} + +function emptyCommandStream(): CommandStreamSlice { + return { start_offset: 0, end_offset: 0, content: "", truncated: false }; +} + +function commandStreamFromEvent( + event: Extract, +): CommandStreamSlice { + return appendCommandStream( + emptyCommandStream(), + event.start_offset, + event.end_offset, + event.content, + ); +} + +function appendCommandStream( + existing: CommandStreamSlice, + startOffset: number, + endOffset: number, + content: string, +): CommandStreamSlice { + if (endOffset <= existing.end_offset) return existing; + const contiguous = startOffset === existing.end_offset; + const combined = contiguous ? `${existing.content}${content}` : content; + const tail = combined.length > COMMAND_STREAM_DISPLAY_BYTES + ? combined.slice(-COMMAND_STREAM_DISPLAY_BYTES) + : combined; + return { + start_offset: endOffset - tail.length, + end_offset: endOffset, + content: tail, + truncated: existing.truncated || !contiguous || tail.length < combined.length || + startOffset > 0, + }; +} + function projectInternalWorkerSnapshot( snapshot: InternalWorkerSnapshot, eventId: string, @@ -256,6 +380,11 @@ function projectInternalWorkerSnapshot( `${eventId}:internal:${snapshot.worker.session_id}:in-flight`, cwd, ); + appendSnapshotCommands( + console, + snapshot.in_flight?.commands ?? [], + `${eventId}:internal:${snapshot.worker.session_id}:command`, + ); if (snapshot.error) { console.lines.push({ id: `${eventId}:internal:${snapshot.worker.session_id}:error`, @@ -403,6 +532,11 @@ export function applyProtocolEvent( `${envelope.eventId}:snapshot-in-flight`, next.cwd, ); + appendSnapshotCommands( + next, + event.data.in_flight?.commands ?? [], + `${envelope.eventId}:snapshot-command`, + ); next.internalWorkers = (event.data.internal_workers ?? []).map((worker) => projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd) ); @@ -436,6 +570,9 @@ export function applyProtocolEvent( case "status": next.status = event.data.status; break; + case "command": + applyCommandEvent(next, envelope.eventId, event.data.event); + break; case "segment_rotated": { const retainedErrors = next.lines.filter((line) => line.kind === "error"); const segment = snapshotProjectionFromEntries( @@ -786,6 +923,10 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine { if (!toolCall) { return item; } + const commandTerminal = toolCall.command !== undefined && + toolCall.command.status !== "running"; + const commandError = toolCall.command !== undefined && + ["failed", "timed_out", "cancelled"].includes(toolCall.command.status); return { ...item, title: item.title.startsWith("Call · Tool result") @@ -794,8 +935,8 @@ function refreshedToolLine(item: ConsoleLine): ConsoleLine { body: renderToolCall(toolCall), detail: toolCallDetail(toolCall), diff: toolCall.name === "Edit" ? editDiff(toolCall) : undefined, - streaming: !["done", "error"].includes(toolCall.state), - error: toolCall.state === "error", + streaming: !["done", "error"].includes(toolCall.state) && !commandTerminal, + error: toolCall.state === "error" || commandError, }; } @@ -1040,9 +1181,37 @@ function renderBashTool(toolCall: ToolCallView): string { const args = parsedArgs(toolCall); const command = stringField(args, "command"); return compactLines([ - `Bash — ${stateSuffix(toolCall.state)}`, + `Bash — ${commandStateSuffix(toolCall)}`, command ? `$ ${command}` : argsText(toolCall), - cappedDisplaySection(resultText(toolCall), 10), + ["done", "error"].includes(toolCall.state) + ? cappedDisplaySection(resultText(toolCall), 10) + : renderLiveCommandOutput(toolCall.command), + ]); +} + +function commandStateSuffix(toolCall: ToolCallView): string { + const command = toolCall.command; + if (!command) return stateSuffix(toolCall.state); + if (command.status === "completed") { + return command.exit_code === null + ? "completed" + : `completed (exit ${command.exit_code})`; + } + if (command.status === "failed") { + return command.exit_code === null ? "failed" : `failed (exit ${command.exit_code})`; + } + if (command.status === "timed_out") return "timed out"; + if (command.status === "cancelled") return "cancelled"; + return "running…"; +} + +function renderLiveCommandOutput(command?: CommandSnapshot): string | undefined { + if (!command) return undefined; + return compactLines([ + command.stdout.truncated ? "[stdout tail; earlier output omitted]" : undefined, + command.stdout.content ? `stdout:\n${command.stdout.content}` : undefined, + command.stderr.truncated ? "[stderr tail; earlier output omitted]" : undefined, + command.stderr.content ? `stderr:\n${command.stderr.content}` : undefined, ]); } From a82234a75ec6cee6839ee8e48fd6077039d384f6 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 10:33:12 +0900 Subject: [PATCH 02/22] docs: report subworker feature installation failure --- ...D01-internal-subworker-feature-installation.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 docs/report/00001KZXWKD01-internal-subworker-feature-installation.md diff --git a/docs/report/00001KZXWKD01-internal-subworker-feature-installation.md b/docs/report/00001KZXWKD01-internal-subworker-feature-installation.md new file mode 100644 index 00000000..d2020762 --- /dev/null +++ b/docs/report/00001KZXWKD01-internal-subworker-feature-installation.md @@ -0,0 +1,15 @@ +# Internal SubWorker feature installation fails before analysis starts + +While implementing Ticket `00001KZXWKD01`, two read-only Internal SubWorkers were requested to investigate the backend and Web Console paths. Both `SubWorkerSpawn` operations failed before the child session started with: + +```text +install Internal Worker features: Worker feature installation failed: +builtin:worker-observation: required service requirement is not available: +builtin:worker.control +``` + +The requested `builtin:coder` child had read-only scope and did not need peer Worker observation for the delegated investigation. The failure prevented context splitting, so the parent Worker performed the investigation directly. No implementation or validation authority was lost. + +## Improvement direction + +Resolve the effective Internal SubWorker Profile so its installed feature set is satisfiable under the parent-provided services. Either install the required `worker.control` service before `worker-observation`, or avoid enabling `worker-observation` for a child that has no corresponding observation grant/service. Startup validation should identify the Profile feature that introduced the unsatisfied dependency and distinguish a configuration error from unavailable delegated authority. From 223a6ed011bd2a66c58ea8bfb9c52758fc9441c6 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 10:42:57 +0900 Subject: [PATCH 03/22] feat: enforce workspace-scoped resource references --- crates/ticket/src/sqlite_schema.rs | 10 +- crates/workspace-server/src/authority.rs | 47 +- crates/workspace-server/src/server.rs | 76 +- crates/workspace-server/src/store.rs | 872 +++++++++++++++++++++-- 4 files changed, 887 insertions(+), 118 deletions(-) diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 14e13bbc..5dca7fc1 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -848,18 +848,16 @@ fn collect_foreign_key_diagnostics( ) }) .collect::>(); + // The Ticket component owns its required foreign keys, while an integrated host may + // strengthen Workspace/domain boundaries with additional references to host-owned + // tables. Reject missing component constraints, but do not treat those host extensions + // as Ticket schema drift. for missing in expected.difference(&actual) { push_diagnostic( diagnostics, format!("table {table:?} is missing foreign key {missing:?}"), ); } - for unexpected in actual.difference(&expected) { - push_diagnostic( - diagnostics, - format!("table {table:?} has unexpected foreign key {unexpected:?}"), - ); - } } fn collect_foreign_key_check_diagnostics( diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 6b038a4c..96dddde0 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -2843,12 +2843,6 @@ mod tests { write_ticket(dir.path(), "00000000001J5", "Second ticket", "planning"); write_ticket(dir.path(), "00000000001J6", "Third ticket", "planning"); let db_path = dir.path().join("workspace.db"); - SqliteTicketBackend::open(&db_path, "workspace-test") - .unwrap() - .import_from_local_backend(&ticket::LocalTicketBackend::new( - dir.path().join(".yoi/tickets"), - )) - .unwrap(); let store = SqliteWorkspaceStore::open(&db_path).unwrap(); store .upsert_workspace(&WorkspaceRecord { @@ -2861,6 +2855,27 @@ mod tests { }) .await .unwrap(); + SqliteTicketBackend::open(&db_path, "workspace-test") + .unwrap() + .import_from_local_backend(&ticket::LocalTicketBackend::new( + dir.path().join(".yoi/tickets"), + )) + .unwrap(); + rusqlite::Connection::open(&db_path) + .unwrap() + .execute_batch( + r#" +INSERT INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at +) VALUES + ('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'), + ('workspace-test', 'ticket', '00000000001J5', 2, 'T-2', '2026-01-01T00:00:00Z'), + ('workspace-test', 'ticket', '00000000001J6', 3, 'T-3', '2026-01-01T00:00:00Z'); +INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) +VALUES ('workspace-test', 'ticket', 4); +"#, + ) + .unwrap(); store .upsert_objective(&ObjectiveRecord { workspace_id: "workspace-test".to_string(), @@ -3216,6 +3231,26 @@ mod tests { }) .await .unwrap(); + rusqlite::Connection::open(&db_path) + .unwrap() + .execute_batch( + r#" +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES + ('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1), + ('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1); +INSERT INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at +) VALUES + ('workspace-test', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'), + ('workspace-test', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z'); +INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) +VALUES ('workspace-test', 'ticket', 3); +"#, + ) + .unwrap(); let authority = SqliteWorkspaceAuthority::new(&db_path, "workspace-test").unwrap(); let created = authority diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 75206cb5..7cdfac14 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -12889,38 +12889,10 @@ mod tests { .is_err() ); - let ticket = browser_ticket_backend(&api) - .unwrap() - .create(create_input) - .unwrap(); - let flow_ticket_launch = WorkerSpawnRequest { - requested_worker_name: Some("cross-workspace-ticket".to_string()), - intent: WorkerSpawnIntent::TicketRole { - ticket_id: ticket.id, - role: TicketWorkerRole::Coder, - }, - acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { - expected_segments: 2, - }, - profile: ProfileSelector::Builtin("builtin:coder".to_string()), - ticket_assignment: None, - initial_submit: vec![ - Segment::Flow { - selector: "builtin:coder-review".to_string(), - }, - Segment::text("Implement the Ticket"), - ], - working_directory_request: None, - resolved_working_directory_request: None, - resolved_working_directory: None, - resolved_config_bundle: None, - resolved_worker_observation_enabled: false, - resolved_worker_observation_grants: Vec::new(), - resolved_control_operation: None, - resolved_workspace_api: None, - }; assert!( - api.validate_worker_spawn_repository_scope(&flow_ticket_launch) + browser_ticket_backend(&api) + .unwrap() + .create(create_input) .is_err() ); @@ -14392,27 +14364,7 @@ mod tests { let mut missing = ticket::NewTicket::new("Missing target"); missing.repository_id = Some("unknown".to_owned()); - let missing = backend.create(missing).unwrap(); - assert!(matches!( - backend.mark_ready( - TicketIdOrSlug::Id(missing.id.clone()), - ticket::TicketMarkReady { - operation_key: "missing-repository".to_owned(), - reason: None, - author: None, - intake_summary: None, - }, - ), - Err(ticket::TicketError::UnknownTargetRepository(_)) - )); - assert_eq!( - backend - .show(TicketIdOrSlug::Id(missing.id)) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::Planning - ); + assert!(backend.create(missing).is_err()); assert!(matches!( backend.set_workflow_state( TicketIdOrSlug::Id(ticket_ref.id), @@ -18947,6 +18899,26 @@ mod tests { }) .await .unwrap(); + rusqlite::Connection::open(&config.database_path) + .unwrap() + .execute_batch( + r#" +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES + ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1), + ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1); +INSERT INTO workspace_resource_human_keys ( + workspace_id, resource_kind, resource_id, sequence, human_key, allocated_at +) VALUES + ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', '00000000001J2', 1, 'T-1', '2026-01-01T00:00:00Z'), + ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', '00000000001J3', 2, 'T-2', '2026-01-01T00:00:00Z'); +INSERT INTO workspace_resource_human_key_counters (workspace_id, resource_kind, next_sequence) +VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); +"#, + ) + .unwrap(); let api = WorkspaceApi::new_with_execution_backend( config, store, diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 6fd3e48b..742c51a4 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -215,6 +215,11 @@ const MIGRATIONS: &[Migration] = &[ name: "add Workspace resource human keys", apply: add_workspace_resource_human_keys, }, + Migration { + version: 39, + name: "enforce Workspace resource foreign keys", + apply: enforce_workspace_resource_foreign_keys, + }, ]; struct Migration { @@ -1030,7 +1035,7 @@ impl SqliteWorkspaceStore { apply_migrations_through(&candidate, i64::MAX)?; ticket::migrate_sqlite_ticket_schema(&candidate)?; merge_request::migrate(&candidate).map_err(|error| Error::Store(error.to_string()))?; - validate_workspace_repository_references(&candidate)?; + validate_workspace_resource_references(&candidate)?; let foreign_key_failures: i64 = candidate.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { row.get(0) @@ -1062,10 +1067,14 @@ impl SqliteWorkspaceStore { pub fn from_connection(conn: Connection) -> Result { configure_sqlite(&conn)?; - apply_migrations(&conn)?; - ticket::migrate_sqlite_ticket_schema(&conn)?; - merge_request::migrate(&conn).map_err(|error| Error::Store(error.to_string()))?; - validate_workspace_repository_references(&conn)?; + apply_migrations(&conn) + .map_err(|error| Error::Store(format!("workspace schema migration failed: {error}")))?; + ticket::migrate_sqlite_ticket_schema(&conn) + .map_err(|error| Error::Store(format!("Ticket schema verification failed: {error}")))?; + merge_request::migrate(&conn).map_err(|error| { + Error::Store(format!("Merge Request schema verification failed: {error}")) + })?; + validate_workspace_resource_references(&conn)?; Ok(Self { conn: Arc::new(Mutex::new(conn)), }) @@ -1789,7 +1798,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { r#"INSERT INTO objectives ( workspace_id, objective_id, title, state, body_md, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(objective_id) DO UPDATE SET + ON CONFLICT(workspace_id, objective_id) DO UPDATE SET workspace_id = excluded.workspace_id, title = excluded.title, state = excluded.state, @@ -1888,7 +1897,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { r#"INSERT INTO objective_ticket_links ( workspace_id, objective_id, ticket_id, kind, created_at ) VALUES (?1, ?2, ?3, ?4, ?5) - ON CONFLICT(objective_id, ticket_id, kind) DO UPDATE SET + ON CONFLICT(workspace_id, objective_id, ticket_id, kind) DO UPDATE SET workspace_id = excluded.workspace_id, created_at = excluded.created_at"#, params![ @@ -1977,7 +1986,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { r#"INSERT INTO objective_resources ( workspace_id, objective_id, resource_path, body, media_type, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) - ON CONFLICT(objective_id, resource_path) DO UPDATE SET + ON CONFLICT(workspace_id, objective_id, resource_path) DO UPDATE SET workspace_id = excluded.workspace_id, body = excluded.body, media_type = excluded.media_type, @@ -4960,7 +4969,7 @@ DROP TABLE IF EXISTS flow_instances; } fn scope_repository_identity_by_workspace(conn: &Connection) -> Result<()> { - validate_workspace_repository_references(conn)?; + validate_workspace_resource_references(conn)?; conn.execute_batch( r#" CREATE TABLE repositories_v27 ( @@ -5119,14 +5128,24 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id Ok(()) } -fn validate_workspace_repository_references(conn: &Connection) -> Result<()> { +fn validate_workspace_resource_references(conn: &Connection) -> Result<()> { + let diagnostics = workspace_resource_reference_diagnostics(conn)?; + if diagnostics.is_empty() { + return Ok(()); + } + Err(Error::Store(format!( + "Workspace resource foreign-key preflight failed:\n- {}", + diagnostics.join("\n- ") + ))) +} + +fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result> { + let mut diagnostics = Vec::new(); for (table, repository_nullable) in [ ("workdir_registry", false), ("artifacts", true), - // `typed_tickets` is owned and migrated by the Ticket component. The control-plane - // migration may reject an already-invalid integrated reference, but must not rebuild - // that component table or claim its schema authority. ("typed_tickets", true), + ("merge_requests", false), ] { if !table_exists(conn, table)? || !column_exists(conn, table, "repository_id")? { continue; @@ -5136,24 +5155,113 @@ fn validate_workspace_repository_references(conn: &Connection) -> Result<()> { } else { "" }; - let sql = format!( - "SELECT child.workspace_id, child.repository_id FROM {table} AS child \ - WHERE {null_filter} NOT EXISTS (\ - SELECT 1 FROM repositories AS repository \ - WHERE repository.workspace_id = child.workspace_id \ - AND repository.repository_id = child.repository_id\ - ) LIMIT 1" - ); - let invalid = conn - .query_row(&sql, [], |row| { - Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) - }) - .optional()?; - if let Some((workspace_id, repository_id)) = invalid { - return Err(Error::Store(format!( - "invalid Workspace-owned repository reference: {table} contains repository `{repository_id}` outside Workspace `{workspace_id}`" - ))); + collect_reference_diagnostics( + conn, + &format!( + "SELECT child.workspace_id || '/' || child.repository_id FROM {table} AS child \ + WHERE {null_filter} NOT EXISTS (\ + SELECT 1 FROM repositories AS parent \ + WHERE parent.workspace_id = child.workspace_id \ + AND parent.repository_id = child.repository_id\ + ) LIMIT 100" + ), + &format!("{table}.repository_id"), + &mut diagnostics, + )?; + } + + for (label, sql) in [ + ( + "typed_ticket_relations.target", + "SELECT relation.workspace_id || '/' || relation.ticket_id || ' -> ' || relation.target \ + FROM typed_ticket_relations AS relation \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS target \ + WHERE target.workspace_id = relation.workspace_id \ + AND target.ticket_id = relation.target) LIMIT 100", + ), + ( + "objective_events.objective_id", + "SELECT child.workspace_id || '/' || child.event_id || ' -> ' || child.objective_id \ + FROM objective_events AS child \ + WHERE NOT EXISTS (SELECT 1 FROM objectives AS parent \ + WHERE parent.workspace_id = child.workspace_id \ + AND parent.objective_id = child.objective_id) LIMIT 100", + ), + ( + "objective_resources.objective_id", + "SELECT child.workspace_id || '/' || child.resource_path || ' -> ' || child.objective_id \ + FROM objective_resources AS child \ + WHERE NOT EXISTS (SELECT 1 FROM objectives AS parent \ + WHERE parent.workspace_id = child.workspace_id \ + AND parent.objective_id = child.objective_id) LIMIT 100", + ), + ( + "objective_ticket_links.objective_id", + "SELECT link.workspace_id || '/' || link.objective_id || ' -> ' || link.ticket_id \ + FROM objective_ticket_links AS link \ + WHERE NOT EXISTS (SELECT 1 FROM objectives AS objective \ + WHERE objective.workspace_id = link.workspace_id \ + AND objective.objective_id = link.objective_id) LIMIT 100", + ), + ( + "objective_ticket_links.ticket_id", + "SELECT link.workspace_id || '/' || link.objective_id || ' -> ' || link.ticket_id \ + FROM objective_ticket_links AS link \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = link.workspace_id \ + AND ticket.ticket_id = link.ticket_id) LIMIT 100", + ), + ( + "artifacts.ticket_id", + "SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.ticket_id \ + FROM artifacts AS artifact WHERE artifact.ticket_id IS NOT NULL \ + AND NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = artifact.workspace_id \ + AND ticket.ticket_id = artifact.ticket_id) LIMIT 100", + ), + ( + "artifacts.worker_ref", + "SELECT artifact.workspace_id || '/' || artifact.artifact_id \ + FROM artifacts AS artifact \ + WHERE (artifact.worker_ref_kind IS NULL) != (artifact.worker_ref_key IS NULL) LIMIT 100", + ), + ( + "artifacts.objective_id", + "SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.objective_id \ + FROM artifacts AS artifact WHERE artifact.objective_id IS NOT NULL \ + AND NOT EXISTS (SELECT 1 FROM objectives AS objective \ + WHERE objective.workspace_id = artifact.workspace_id \ + AND objective.objective_id = artifact.objective_id) LIMIT 100", + ), + ] { + let Some(table) = label.split('.').next() else { + continue; + }; + if !table_exists(conn, table)? { + continue; } + if sql.contains("typed_tickets") && !table_exists(conn, "typed_tickets")? { + continue; + } + if sql.contains("worker.worker_id") && !column_exists(conn, "worker_registry", "worker_id")? + { + continue; + } + collect_reference_diagnostics(conn, sql, label, &mut diagnostics)?; + } + Ok(diagnostics) +} + +fn collect_reference_diagnostics( + conn: &Connection, + sql: &str, + label: &str, + diagnostics: &mut Vec, +) -> Result<()> { + let mut statement = conn.prepare(sql)?; + let rows = statement.query_map([], |row| row.get::<_, String>(0))?; + for row in rows { + diagnostics.push(format!("{label}: {}", row?)); } Ok(()) } @@ -5934,11 +6042,384 @@ pub(crate) fn materialize_main_config_entrypoint(conn: &Connection) -> Result<() Ok(()) } +fn enforce_workspace_resource_foreign_keys(conn: &Connection) -> Result<()> { + let schema = r#" +CREATE UNIQUE INDEX IF NOT EXISTS idx_worker_registry_workspace_runtime_worker + ON worker_registry(workspace_id, runtime_id, worker_id); + +CREATE TABLE objectives_v39 ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL, + body_md TEXT NOT NULL, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +INSERT INTO objectives_v39 SELECT * FROM objectives; + +CREATE TABLE objective_events_v39 ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + event_id TEXT NOT NULL, + kind TEXT NOT NULL, + body_md TEXT, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +INSERT INTO objective_events_v39 +SELECT workspace_id, objective_id, event_id, kind, body_md, created_at FROM objective_events; + +CREATE TABLE objective_resources_v39 ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + resource_path TEXT NOT NULL, + body TEXT NOT NULL, + media_type TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, resource_path), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE +); +INSERT INTO objective_resources_v39 +SELECT workspace_id, objective_id, resource_path, body, media_type, created_at, updated_at +FROM objective_resources; + +CREATE TABLE typed_tickets_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + repository_id TEXT, + ref_selector TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +INSERT INTO typed_tickets_v39 SELECT * FROM typed_tickets; + +CREATE TABLE typed_ticket_relations_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + kind TEXT NOT NULL, + target TEXT NOT NULL, + note TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, kind, target), + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, target) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +INSERT INTO typed_ticket_relations_v39 SELECT * FROM typed_ticket_relations; + +CREATE TABLE objective_ticket_links_v39 ( + workspace_id TEXT NOT NULL, + objective_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + kind TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, objective_id, ticket_id, kind), + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +INSERT INTO objective_ticket_links_v39 SELECT * FROM objective_ticket_links; + +CREATE TABLE ticket_worker_assignments_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + assigned_by TEXT NOT NULL, + assigned_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, assignment_id), + UNIQUE (workspace_id, ticket_id, assignment_id) +); +INSERT INTO ticket_worker_assignments_v39 SELECT * FROM ticket_worker_assignments; + +CREATE TABLE ticket_worker_assignment_events_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + event_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assigned', 'reassigned', 'unassigned')), + assignment_id TEXT, + previous_assignment_id TEXT, + actor TEXT NOT NULL, + created_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, event_id) +); +INSERT INTO ticket_worker_assignment_events_v39 SELECT * FROM ticket_worker_assignment_events; + +CREATE TABLE ticket_current_worker_assignments_v39 ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + UNIQUE (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id, ticket_id, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, ticket_id, assignment_id) + ON DELETE CASCADE +); +INSERT INTO ticket_current_worker_assignments_v39 SELECT * FROM ticket_current_worker_assignments; + +CREATE TABLE ticket_assignment_operations_v39 ( + workspace_id TEXT NOT NULL, + operation_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('assign', 'reassign', 'unassign')), + ticket_id TEXT NOT NULL, + runtime_id TEXT, + worker_id TEXT, + assignment_id TEXT, + expected_assignment_id TEXT, + created_at TEXT NOT NULL, + request_fingerprint TEXT, + PRIMARY KEY (workspace_id, operation_id) +); +INSERT INTO ticket_assignment_operations_v39 SELECT * FROM ticket_assignment_operations; + +CREATE TABLE artifacts_v39 ( + workspace_id TEXT NOT NULL, + artifact_id TEXT NOT NULL, + kind TEXT NOT NULL, + uri TEXT NOT NULL, + media_type TEXT, + sha256 TEXT, + size_bytes INTEGER, + summary TEXT, + created_at TEXT NOT NULL, + created_by_kind TEXT NOT NULL, + created_by_key TEXT NOT NULL, + created_by_display TEXT NOT NULL, + created_by_source_kind TEXT, + created_by_source_key TEXT, + ticket_id TEXT, + objective_id TEXT, + event_id TEXT, + worker_ref_kind TEXT, + worker_ref_key TEXT, + worker_display TEXT, + repository_id TEXT, + source_kind TEXT, + source_revision TEXT, + PRIMARY KEY (workspace_id, artifact_id), + CHECK ((worker_ref_kind IS NULL) = (worker_ref_key IS NULL)), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, objective_id) + REFERENCES objectives(workspace_id, objective_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, repository_id) + REFERENCES repositories(workspace_id, repository_id) ON DELETE RESTRICT +); +INSERT INTO artifacts_v39 SELECT * FROM artifacts; + +CREATE TABLE workspace_resource_human_key_counters_v39 ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + next_sequence INTEGER NOT NULL CHECK (next_sequence > 0), + PRIMARY KEY (workspace_id, resource_kind), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +INSERT INTO workspace_resource_human_key_counters_v39 SELECT * FROM workspace_resource_human_key_counters; + +CREATE TABLE workspace_resource_human_keys_v39 ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + resource_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + human_key TEXT NOT NULL, + allocated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, resource_kind, resource_id), + UNIQUE (workspace_id, resource_kind, sequence), + UNIQUE (workspace_id, human_key), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +INSERT INTO workspace_resource_human_keys_v39 SELECT * FROM workspace_resource_human_keys; + +DROP TABLE ticket_current_worker_assignments; +DROP TABLE ticket_worker_assignment_events; +DROP TABLE ticket_assignment_operations; +DROP TABLE objective_ticket_links; +DROP TABLE objective_events; +DROP TABLE objective_resources; +DROP TABLE typed_ticket_relations; + +ALTER TABLE ticket_worker_assignments RENAME TO ticket_worker_assignments_v38; +ALTER TABLE typed_tickets RENAME TO typed_tickets_v38; +ALTER TABLE objectives RENAME TO objectives_v38; + +ALTER TABLE objectives_v39 RENAME TO objectives; +ALTER TABLE typed_tickets_v39 RENAME TO typed_tickets; +ALTER TABLE ticket_worker_assignments_v39 RENAME TO ticket_worker_assignments; +ALTER TABLE objective_events_v39 RENAME TO objective_events; +ALTER TABLE objective_resources_v39 RENAME TO objective_resources; +ALTER TABLE typed_ticket_relations_v39 RENAME TO typed_ticket_relations; +ALTER TABLE objective_ticket_links_v39 RENAME TO objective_ticket_links; +ALTER TABLE ticket_worker_assignment_events_v39 RENAME TO ticket_worker_assignment_events; +ALTER TABLE ticket_current_worker_assignments_v39 RENAME TO ticket_current_worker_assignments; +ALTER TABLE ticket_assignment_operations_v39 RENAME TO ticket_assignment_operations; + +DROP TABLE ticket_worker_assignments_v38; +DROP TABLE typed_tickets_v38; +DROP TABLE objectives_v38; + +DROP TABLE artifacts; +ALTER TABLE artifacts_v39 RENAME TO artifacts; +DROP TABLE workspace_resource_human_keys; +DROP TABLE workspace_resource_human_key_counters; +ALTER TABLE workspace_resource_human_key_counters_v39 RENAME TO workspace_resource_human_key_counters; +ALTER TABLE workspace_resource_human_keys_v39 RENAME TO workspace_resource_human_keys; + +CREATE INDEX IF NOT EXISTS idx_objectives_workspace_updated + ON objectives(workspace_id, updated_at DESC); +CREATE INDEX IF NOT EXISTS idx_objective_events_workspace_created + ON objective_events(workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_objective_resources_workspace_objective + ON objective_resources(workspace_id, objective_id); +CREATE INDEX IF NOT EXISTS idx_objective_ticket_links_workspace_objective + ON objective_ticket_links(workspace_id, objective_id); +CREATE INDEX IF NOT EXISTS idx_objective_ticket_links_workspace_ticket + ON objective_ticket_links(workspace_id, ticket_id); +CREATE INDEX IF NOT EXISTS idx_typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); +CREATE INDEX IF NOT EXISTS idx_typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); +CREATE INDEX IF NOT EXISTS idx_typed_ticket_relations_workspace_target + ON typed_ticket_relations(workspace_id, target, at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_ticket + ON ticket_worker_assignments(workspace_id, ticket_id, assigned_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_worker + ON ticket_worker_assignments(workspace_id, runtime_id, worker_id, assigned_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignment_events_ticket + ON ticket_worker_assignment_events(workspace_id, ticket_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_assignment_operations_ticket + ON ticket_assignment_operations(workspace_id, ticket_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_artifacts_workspace_created + ON artifacts(workspace_id, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_workspace_resource_human_keys_reverse + ON workspace_resource_human_keys(workspace_id, resource_kind, human_key); +"#; + for statement in schema + .split(';') + .map(str::trim) + .filter(|sql| !sql.is_empty()) + { + conn.execute_batch(statement).map_err(|error| { + Error::Store(format!( + "Workspace resource FK migration statement failed: {statement}: {error}" + )) + })?; + } + Ok(()) +} + pub(crate) fn apply_migrations_through(conn: &Connection, through_version: i64) -> Result<()> { let current = current_schema_version(conn)?; for migration in MIGRATIONS.iter().filter(|migration| { i64::from(migration.version) > current && i64::from(migration.version) <= through_version }) { + if migration.version == 39 { + ticket::migrate_sqlite_ticket_schema(conn).map_err(|error| { + Error::Store(format!( + "migration 39 Ticket schema preparation failed: {error}" + )) + })?; + if !table_exists(conn, "typed_tickets")? { + return Err(Error::Store( + "migration 39 Ticket schema preparation created no typed_tickets".to_string(), + )); + } + merge_request::migrate(conn).map_err(|error| { + Error::Store(format!( + "migration 39 Merge Request schema preparation failed: {error}" + )) + })?; + validate_workspace_resource_references(conn) + .map_err(|error| Error::Store(format!("migration 39 preflight failed: {error}")))?; + conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; + let result = (|| -> Result<()> { + let tx = conn.unchecked_transaction()?; + (migration.apply)(&tx)?; + if !table_exists(&tx, "typed_tickets")? { + return Err(Error::Store( + "migration 39 did not materialize `typed_tickets`".to_string(), + )); + } + let dangling_foreign_key: Option<(String, String)> = tx + .query_row( + "SELECT name, sql FROM sqlite_schema \ + WHERE type = 'table' AND (sql LIKE '%_v38%' OR sql LIKE '%_v39%') LIMIT 1", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((child, sql)) = dangling_foreign_key { + return Err(Error::Store(format!( + "migration 39 left a temporary reference in `{child}`: {sql}" + ))); + } + let foreign_key_failures: i64 = tx + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .map_err(|error| { + Error::Store(format!( + "migration 39 could not evaluate foreign keys: {error}" + )) + })?; + if foreign_key_failures != 0 { + return Err(Error::Store(format!( + "migration 39 found {foreign_key_failures} foreign key violation(s)" + ))); + } + tx.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (?1, ?2)", + params![migration.version, migration.name], + ) + .map_err(|error| { + Error::Store(format!("migration 39 version insert failed: {error}")) + })?; + tx.commit().map_err(|error| { + Error::Store(format!("migration 39 commit failed: {error}")) + })?; + Ok(()) + })(); + conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") + .map_err(|error| { + Error::Store(format!( + "migration 39 could not restore FK enforcement: {error}" + )) + })?; + result?; + continue; + } + let tx = conn.unchecked_transaction()?; if migration.version == 37 { crate::retention::repair_worker_diagnostics_archive_table(&tx)?; @@ -5954,7 +6435,14 @@ pub(crate) fn apply_migrations_through(conn: &Connection, through_version: i64) } fn apply_migrations(conn: &Connection) -> Result<()> { - apply_migrations_through(conn, i64::MAX) + let latest = i64::from(MIGRATIONS.last().expect("at least one migration").version); + let current = current_schema_version(conn)?; + if current > latest { + return Err(Error::Store(format!( + "database schema version {current} is newer than this server supports ({latest}); refusing to serve with an older binary" + ))); + } + apply_migrations_through(conn, latest) } fn align_legacy_bootstrap_schema(conn: &Connection) -> Result<()> { @@ -6443,7 +6931,7 @@ mod tests { let conn = Connection::open_in_memory().unwrap(); configure_sqlite(&conn).unwrap(); apply_migrations(&conn).unwrap(); - assert!(!table_exists(&conn, "ticket_schema_migrations").unwrap()); + assert!(table_exists(&conn, "ticket_schema_migrations").unwrap()); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); store @@ -6482,7 +6970,7 @@ mod tests { let before = std::fs::read(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, 36); - assert_eq!(plan.target_schema_version, 38); + assert_eq!(plan.target_schema_version, 39); assert!(plan.migration_required); assert_eq!(plan.worker_count, 1); assert_eq!(plan.mappings[0].legacy_worker_id, 7); @@ -6496,7 +6984,7 @@ mod tests { store .with_conn(|conn| { assert!(table_exists(conn, "worker_diagnostics_archives")?); - assert_eq!(current_schema_version(conn)?, 38); + assert_eq!(current_schema_version(conn)?, 39); Ok(()) }) .unwrap(); @@ -6575,7 +7063,7 @@ mod tests { ), ] ); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); let foreign_key_error: Option = conn .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .optional() @@ -6647,11 +7135,16 @@ CREATE TABLE ticket_worker_links (ticket_id TEXT, worker_ref_key TEXT); .unwrap(); tx.commit().unwrap(); } + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); conn.execute_batch( r#" INSERT INTO workspaces ( workspace_id, display_name, state, created_at, updated_at ) VALUES ('workspace-a', 'Workspace A', 'active', '1', '1'); +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', 'task', 'normal', '', 'planning', 1); INSERT INTO worker_registry ( workspace_id, runtime_id, runtime_worker_id, display_name, retention_state, created_at, updated_at @@ -6699,7 +7192,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -6817,7 +7310,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -6850,7 +7343,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -6917,7 +7410,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!(current_schema_version(&conn).unwrap(), 39); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -7038,8 +7531,7 @@ INSERT INTO workdir_registry ( } #[tokio::test] - async fn startup_rejects_cross_workspace_ticket_repository_reference_without_claiming_ticket_schema() - { + async fn workspace_schema_rejects_cross_workspace_ticket_repository_reference_at_write_time() { let dir = tempfile::tempdir().unwrap(); let database_path = dir.path().join("workspace.sqlite"); let store = SqliteWorkspaceStore::open(&database_path).unwrap(); @@ -7080,15 +7572,14 @@ INSERT INTO workdir_registry ( .unwrap(); let mut input = ticket::NewTicket::new("Foreign repository"); input.repository_id = Some("main".to_string()); - ticket::TicketBackend::create(&backend, input).unwrap(); + let error = ticket::TicketBackend::create(&backend, input).unwrap_err(); + assert!( + error.to_string().contains("FOREIGN KEY constraint failed"), + "{error}" + ); drop(backend); - let error = match SqliteWorkspaceStore::open(&database_path) { - Ok(_) => panic!("cross-Workspace Ticket repository reference must fail closed"), - Err(error) => error, - }; - assert!(error.to_string().contains("typed_tickets")); - assert!(error.to_string().contains("workspace-b")); + SqliteWorkspaceStore::open(&database_path).unwrap(); } #[tokio::test] @@ -7097,7 +7588,7 @@ INSERT INTO workdir_registry ( let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -7114,7 +7605,7 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 38); + assert_eq!(reopened.schema_version().await.unwrap(), 39); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -7340,6 +7831,29 @@ INSERT INTO workdir_registry ( }) .await .unwrap(); + store + .with_conn(|conn| { + conn.execute_batch( + r#" +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES + ('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', 'open', 'task', 'normal', '', 'planning', 1), + ('workspace-a', 'ticket-2', 'ticket-2', 'Ticket 2', 'open', 'task', 'normal', '', 'planning', 1), + ('workspace-a', 'ticket-3', 'ticket-3', 'Ticket 3', 'open', 'task', 'normal', '', 'planning', 1); +INSERT INTO worker_registry ( + workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at +) VALUES + ('workspace-a', 'runtime-1', 'worker-1', 'Worker 1', 'normal', '1', '1'), + ('workspace-a', 'runtime-1', 'worker-other', 'Other Worker', 'normal', '1', '1'), + ('workspace-a', 'runtime-2', 'worker-2', 'Worker 2', 'normal', '1', '1'), + ('workspace-a', 'runtime-3', 'worker-3', 'Worker 3', 'normal', '1', '1'); +"#, + )?; + Ok(()) + }) + .unwrap(); let first = TicketWorkerAssignmentRecord { workspace_id: "workspace-a".to_string(), @@ -7584,6 +8098,256 @@ INSERT INTO workdir_registry ( ); } + #[test] + fn server_refuses_a_database_from_a_newer_schema_generation() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations(&conn).unwrap(); + conn.execute( + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (40, 'future')", + [], + ) + .unwrap(); + + let error = apply_migrations(&conn).unwrap_err().to_string(); + assert!(error.contains("schema version 40 is newer"), "{error}"); + assert!(error.contains("refusing to serve"), "{error}"); + } + + #[test] + fn migration_plan_lists_workspace_reference_violations_without_mutating_source() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 38).unwrap(); + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + conn.execute_batch( + r#" +INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) VALUES + ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01'), + ('workspace-b', 'B', 'active', '2026-01-01', '2026-01-01'); +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES + ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', 'planning', 1), + ('workspace-b', 'ticket-b', 'ticket-b', 'B', 'open', 'task', 'normal', '', 'planning', 1); +INSERT INTO typed_ticket_relations (workspace_id, ticket_id, kind, target, note, author, at) +VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-01-01'); +"#, + ) + .unwrap(); + drop(conn); + + let error = SqliteWorkspaceStore::migration_plan(&path) + .unwrap_err() + .to_string(); + assert!(error.contains("typed_ticket_relations.target"), "{error}"); + assert!( + error.contains("workspace-b/ticket-b -> ticket-a"), + "{error}" + ); + + let source = Connection::open(&path).unwrap(); + assert_eq!(current_schema_version(&source).unwrap(), 38); + assert_eq!( + source + .query_row("SELECT COUNT(*) FROM typed_ticket_relations", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 1 + ); + } + + #[test] + fn workspace_resource_fk_migration_rolls_back_constraint_failures() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 38).unwrap(); + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + conn.execute( + "INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) \ + VALUES ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01')", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, \ + workflow_state, workflow_state_explicit, repository_id, ref_selector) \ + VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', \ + 'planning', 1, NULL, 'develop')", + [], + ) + .unwrap(); + + conn.execute_batch("CREATE TABLE typed_tickets_v39 (sentinel TEXT)") + .unwrap(); + + let error = apply_migrations_through(&conn, 39).unwrap_err().to_string(); + assert!(error.contains("CREATE TABLE typed_tickets_v39"), "{error}"); + assert_eq!(current_schema_version(&conn).unwrap(), 38); + assert_eq!( + conn.query_row( + "SELECT ref_selector FROM typed_tickets WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'", + [], + |row| row.get::<_, String>(0), + ) + .unwrap(), + "develop" + ); + assert!(table_exists(&conn, "typed_tickets_v39").unwrap()); + assert!(!table_exists(&conn, "objectives_v39").unwrap()); + let foreign_keys_enabled: i64 = conn + .query_row("PRAGMA foreign_keys", [], |row| row.get(0)) + .unwrap(); + assert_eq!(foreign_keys_enabled, 1); + } + + #[test] + fn workspace_resource_fk_migration_preflights_and_enforces_composite_identity() { + let conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 38).unwrap(); + ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + merge_request::migrate(&conn).unwrap(); + + conn.execute_batch( + r#" +INSERT INTO workspaces ( + workspace_id, display_name, state, created_at, updated_at +) VALUES + ('workspace-a', 'A', 'active', '2026-01-01', '2026-01-01'), + ('workspace-b', 'B', 'active', '2026-01-01', '2026-01-01'); +INSERT INTO repositories ( + workspace_id, repository_id, name, kind, provider, uri, default_ref, created_at, updated_at +) VALUES ('workspace-a', 'repo-a', 'Repo A', 'git', 'git', '/repo-a', 'develop', '2026-01-01', '2026-01-01'); +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + created_at, updated_at, workflow_state, workflow_state_explicit, + repository_id, ref_selector +) VALUES + ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', + '2026-01-01', '2026-01-01', 'planning', 1, 'repo-a', 'develop'), + ('workspace-b', 'ticket-b', 'ticket-b', 'B', 'open', 'task', 'normal', '', + '2026-01-01', '2026-01-01', 'planning', 1, NULL, NULL); +INSERT INTO objectives ( + workspace_id, objective_id, title, state, body_md, created_at, updated_at +) VALUES ('workspace-a', 'objective-a', 'A', 'active', '', '2026-01-01', '2026-01-01'); +INSERT INTO typed_ticket_relations ( + workspace_id, ticket_id, kind, target, note, author, at +) VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-01-01'); +INSERT INTO objective_ticket_links ( + workspace_id, objective_id, ticket_id, kind, created_at +) VALUES ('workspace-b', 'objective-a', 'ticket-b', 'tracks', '2026-01-01'); +"#, + ) + .unwrap(); + + let error = apply_migrations_through(&conn, 39).unwrap_err().to_string(); + assert!(error.contains("typed_ticket_relations.target"), "{error}"); + assert!( + error.contains("workspace-b/ticket-b -> ticket-a"), + "{error}" + ); + assert!( + error.contains("objective_ticket_links.objective_id"), + "{error}" + ); + assert_eq!(current_schema_version(&conn).unwrap(), 38); + + conn.execute("DELETE FROM typed_ticket_relations", []) + .unwrap(); + conn.execute("DELETE FROM objective_ticket_links", []) + .unwrap(); + apply_migrations_through(&conn, 39).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 39); + + let bad_target = conn.execute( + "UPDATE typed_tickets SET repository_id = 'repo-a', ref_selector = 'develop' \ + WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b'", + [], + ); + assert!(bad_target.is_err()); + let cross_relation = conn.execute( + "INSERT INTO typed_ticket_relations \ + (workspace_id, ticket_id, kind, target, note, author, at) \ + VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026-01-01')", + [], + ); + assert!(cross_relation.is_err()); + let cross_objective_link = conn.execute( + "INSERT INTO objective_ticket_links \ + (workspace_id, objective_id, ticket_id, kind, created_at) \ + VALUES ('workspace-b', 'objective-a', 'ticket-b', 'tracks', '2026-01-01')", + [], + ); + assert!(cross_objective_link.is_err()); + assert!( + conn.execute( + "DELETE FROM repositories WHERE workspace_id = 'workspace-a' AND repository_id = 'repo-a'", + [], + ) + .is_err() + ); + conn.execute( + "INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, \ + workflow_state, workflow_state_explicit) VALUES \ + ('workspace-b', 'ticket-b2', 'ticket-b2', 'B2', 'open', 'task', 'normal', '', 'planning', 1)", + [], + ) + .unwrap(); + conn.execute( + "INSERT INTO typed_ticket_relations \ + (workspace_id, ticket_id, kind, target, note, author, at) \ + VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-b2', NULL, 'tester', '2026-01-01')", + [], + ) + .unwrap(); + conn.execute( + "DELETE FROM typed_tickets WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b2'", + [], + ) + .unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM typed_ticket_relations WHERE workspace_id = 'workspace-b'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + conn.execute( + "DELETE FROM workspaces WHERE workspace_id = 'workspace-b'", + [], + ) + .unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM typed_tickets WHERE workspace_id = 'workspace-b'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); + + let foreign_key_failures: i64 = conn + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(foreign_key_failures, 0); + let integrity: String = conn + .query_row("PRAGMA integrity_check", [], |row| row.get(0)) + .unwrap(); + assert_eq!(integrity, "ok"); + } + #[test] fn fresh_schema_matches_workspace_db_v0_boundaries() { let conn = Connection::open_in_memory().unwrap(); @@ -7790,7 +8554,7 @@ INSERT INTO workdir_registry ( .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); store .with_conn(|conn| { @@ -7979,7 +8743,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -8045,7 +8809,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -8436,7 +9200,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 38); + assert_eq!(store.schema_version().await.unwrap(), 39); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), From 4583b512b3bd336d6382bf0f8009779cb69fcb0c Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 10:43:05 +0900 Subject: [PATCH 04/22] docs: add workspace schema migration runbook --- docs/README.md | 1 + .../workspace-schema-migrations.md | 41 +++++++++++++++++++ 2 files changed, 42 insertions(+) create mode 100644 docs/development/workspace-schema-migrations.md diff --git a/docs/README.md b/docs/README.md index ba2ced5f..4cd18397 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,7 @@ It is not a dumping ground for external research, old plans, API inventories, or 14. [`development/work-items.md`](development/work-items.md) — how project work is recorded and reviewed. 15. [`development/rust-testing-strategy.md`](development/rust-testing-strategy.md) — what Yoi Rust tests should prove, where they belong, and how to name them. 16. [`development/validation.md`](development/validation.md) — how to check changes. +17. [`development/workspace-schema-migrations.md`](development/workspace-schema-migrations.md) — how to preflight, apply, verify, and roll back control-plane SQLite schema changes. ## What belongs here diff --git a/docs/development/workspace-schema-migrations.md b/docs/development/workspace-schema-migrations.md new file mode 100644 index 00000000..5e878942 --- /dev/null +++ b/docs/development/workspace-schema-migrations.md @@ -0,0 +1,41 @@ +# Workspace database schema migration runbook + +The Workspace Server owns one control-plane SQLite database. Schema changes are applied by the Server at startup; domain components such as Ticket and Merge Request contribute tables to that same database, but they do not create a second Workspace authority. + +## Before deployment + +1. Stop writes and shut down every Server process using the database. Do not run two Server generations against one database during migration. +2. Record the current binary revision and database schema version. +3. Take a byte-for-byte backup of the database and its WAL/SHM state using a SQLite-safe backup procedure. +4. Run the read-only plan with the new binary: + + ```sh + yoi-server migrate --dry-run --database + ``` + + The plan runs against an in-memory copy. It reports the current and target schema versions, migration names, Worker identity mappings, and repairs without mutating the source database. Workspace-resource preflight failures name the relation and bounded offending row identities; repair those rows through the owning domain authority before retrying. + +## Applying + +Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration: + +- rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity; +- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and assignment references; +- checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and +- restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back. + +After startup, verify: + +```sql +SELECT MAX(version) FROM __yoi_schema_migrations; +PRAGMA foreign_key_check; +PRAGMA integrity_check; +``` + +The expected migration version is `39`, `foreign_key_check` returns no rows, and `integrity_check` returns `ok`. + +## Failure and rollback + +There is no in-place down migration. A failed migration transaction leaves the prior schema version and data intact. Keep the Server stopped, preserve the failure diagnostics, and either repair the preflight data with the prior generation or restore the complete pre-migration backup before retrying. + +Never run an older binary after a newer schema version has committed. Startup fences this case and refuses to serve when the database schema version is newer than the binary supports. Rollback therefore means restoring both the prior binary and its matching pre-migration database backup; it does not mean pointing the old binary at the upgraded database. From 560226dea2af1996a2fadaa4eaf053a0a30b09d6 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 11:06:31 +0900 Subject: [PATCH 05/22] fix: enforce assignment workspace references --- crates/workspace-server/src/retention.rs | 9 + crates/workspace-server/src/server.rs | 51 ++++ crates/workspace-server/src/store.rs | 287 +++++++++++++++++- .../workspace-schema-migrations.md | 3 +- 4 files changed, 343 insertions(+), 7 deletions(-) diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index 3b34a6db..58d2baf8 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -1082,6 +1082,11 @@ mod tests { ) VALUES('w',?1,'r','one','builtin:coder','normal','created','rev1')", [worker_id().to_string()], )?; + c.execute( + "INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \ + VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'planning', 1)", + [], + )?; Ok(()) }) .unwrap(); @@ -1267,7 +1272,11 @@ mod tests { fn purge_tombstone_commit_is_idempotent() { let s = setup(); s.with_conn(|conn| { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?; + conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','rev1')", [])?; conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?; + conn.execute("DELETE FROM worker_registry WHERE workspace_id='w' AND runtime_id='r' AND worker_id='1'", [])?; + conn.execute("DELETE FROM typed_tickets WHERE workspace_id='w' AND ticket_id='ticket-old'", [])?; Ok(()) }).unwrap(); let p = s.plan_worker_removal(&req(), &inv()).unwrap(); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 7cdfac14..578ad3d0 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -14556,6 +14556,21 @@ mod tests { .create(ticket::NewTicket::new("Assigned Ticket")) .unwrap(); let ticket_id = created.id; + api.store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: RuntimeWorkerRef::new("embedded", "42"), + display_name: "Worker 42".to_string(), + profile: Some("builtin:coder".to_string()), + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + }) + .unwrap(); let assignment = TicketWorkerAssignmentRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), ticket_id: ticket_id.clone(), @@ -14650,6 +14665,24 @@ mod tests { .unwrap() .worker .unwrap(); + api.store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + source_worker.worker.worker_id.clone(), + ), + display_name: "Source Worker".to_string(), + profile: Some("builtin:coder".to_string()), + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + }) + .unwrap(); let recipient_worker = api .runtime .spawn_worker( @@ -14660,6 +14693,24 @@ mod tests { .unwrap() .worker .unwrap(); + api.store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + worker: RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + recipient_worker.worker.worker_id.clone(), + ), + display_name: "Recipient Worker".to_string(), + profile: Some("builtin:coder".to_string()), + retention_state: "normal".to_string(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: TEST_CREATED_AT.to_string(), + updated_at: TEST_CREATED_AT.to_string(), + }) + .unwrap(); let backend = browser_ticket_backend(&api).unwrap(); let ticket_ref = backend .create(ticket::NewTicket::new("Notify assigned Worker")) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 742c51a4..8069d018 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1075,6 +1075,7 @@ impl SqliteWorkspaceStore { Error::Store(format!("Merge Request schema verification failed: {error}")) })?; validate_workspace_resource_references(&conn)?; + verify_workspace_resource_constraints(&conn)?; Ok(Self { conn: Arc::new(Mutex::new(conn)), }) @@ -5128,6 +5129,30 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id Ok(()) } +fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? < 39 { + return Ok(()); + } + for trigger in [ + "ticket_worker_assignments_validate_insert", + "ticket_worker_assignments_validate_update", + "ticket_worker_assignment_events_validate_insert", + "ticket_assignment_operations_validate_insert", + ] { + let exists = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type = 'trigger' AND name = ?1)", + [trigger], + |row| row.get::<_, bool>(0), + )?; + if !exists { + return Err(Error::Store(format!( + "Workspace resource constraint trigger `{trigger}` is missing" + ))); + } + } + Ok(()) +} + fn validate_workspace_resource_references(conn: &Connection) -> Result<()> { let diagnostics = workspace_resource_reference_diagnostics(conn)?; if diagnostics.is_empty() { @@ -5211,6 +5236,55 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ + FROM ticket_worker_assignments AS assignment \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = assignment.workspace_id \ + AND ticket.ticket_id = assignment.ticket_id) LIMIT 100", + ), + ( + "ticket_worker_assignments.worker_id", + "SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || assignment.runtime_id || '/' || assignment.worker_id \ + FROM ticket_worker_assignments AS assignment \ + WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \ + WHERE worker.workspace_id = assignment.workspace_id \ + AND worker.runtime_id = assignment.runtime_id \ + AND worker.worker_id = assignment.worker_id) LIMIT 100", + ), + ( + "ticket_current_worker_assignments.assignment_id", + "SELECT current.workspace_id || '/' || current.assignment_id \ + FROM ticket_current_worker_assignments AS current \ + WHERE NOT EXISTS (SELECT 1 FROM ticket_worker_assignments AS assignment \ + WHERE assignment.workspace_id = current.workspace_id \ + AND assignment.ticket_id = current.ticket_id \ + AND assignment.assignment_id = current.assignment_id \ + AND assignment.runtime_id = current.runtime_id \ + AND assignment.worker_id = current.worker_id) LIMIT 100", + ), + ( + "ticket_worker_assignment_events.assignment_id", + "SELECT event.workspace_id || '/' || event.event_id \ + FROM ticket_worker_assignment_events AS event \ + WHERE (event.assignment_id IS NOT NULL AND NOT EXISTS (\ + SELECT 1 FROM ticket_worker_assignments AS assignment \ + WHERE assignment.workspace_id = event.workspace_id \ + AND assignment.assignment_id = event.assignment_id)) \ + OR (event.previous_assignment_id IS NOT NULL AND NOT EXISTS (\ + SELECT 1 FROM ticket_worker_assignments AS assignment \ + WHERE assignment.workspace_id = event.workspace_id \ + AND assignment.assignment_id = event.previous_assignment_id)) LIMIT 100", + ), + ( + "ticket_assignment_operations.ticket_id", + "SELECT operation.workspace_id || '/' || operation.operation_id || ' -> ' || operation.ticket_id \ + FROM ticket_assignment_operations AS operation \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = operation.workspace_id \ + AND ticket.ticket_id = operation.ticket_id) LIMIT 100", + ), ( "artifacts.ticket_id", "SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.ticket_id \ @@ -5247,6 +5321,16 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result panic!("missing assignment constraint trigger must fail closed"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("ticket_worker_assignments_validate_insert"), + "{error}" + ); + } + #[test] fn migration_plan_lists_workspace_reference_violations_without_mutating_source() { let temp = tempfile::tempdir().unwrap(); @@ -8243,6 +8424,17 @@ INSERT INTO typed_ticket_relations ( INSERT INTO objective_ticket_links ( workspace_id, objective_id, ticket_id, kind, created_at ) VALUES ('workspace-b', 'objective-a', 'ticket-b', 'tracks', '2026-01-01'); +INSERT INTO worker_registry ( + workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at +) VALUES + ('workspace-a', 'runtime-a', '00000000-0000-7000-8000-000000000001', 'Worker A', 'normal', '2026-01-01', '2026-01-01'), + ('workspace-b', 'runtime-b', '00000000-0000-7000-8000-000000000002', 'Worker B', 'normal', '2026-01-01', '2026-01-01'); +INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at +) VALUES ( + 'workspace-b', 'ticket-b', 'assignment-cross-worker', 'runtime-a', + '00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01' +); "#, ) .unwrap(); @@ -8257,12 +8449,19 @@ INSERT INTO objective_ticket_links ( error.contains("objective_ticket_links.objective_id"), "{error}" ); + assert!( + error.contains("ticket_worker_assignments.worker_id"), + "{error}" + ); + assert!(error.contains("assignment-cross-worker"), "{error}"); assert_eq!(current_schema_version(&conn).unwrap(), 38); conn.execute("DELETE FROM typed_ticket_relations", []) .unwrap(); conn.execute("DELETE FROM objective_ticket_links", []) .unwrap(); + conn.execute("DELETE FROM ticket_worker_assignments", []) + .unwrap(); apply_migrations_through(&conn, 39).unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 39); @@ -8286,6 +8485,53 @@ INSERT INTO objective_ticket_links ( [], ); assert!(cross_objective_link.is_err()); + let cross_ticket_assignment = conn.execute( + "INSERT INTO ticket_worker_assignments \ + (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ + VALUES ('workspace-b', 'ticket-a', 'assignment-cross-ticket', 'runtime-b', \ + '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')", + [], + ); + assert!(cross_ticket_assignment.is_err()); + let cross_worker_assignment = conn.execute( + "INSERT INTO ticket_worker_assignments \ + (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ + VALUES ('workspace-b', 'ticket-b', 'assignment-cross-worker', 'runtime-a', \ + '00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01')", + [], + ); + assert!(cross_worker_assignment.is_err()); + conn.execute( + "INSERT INTO ticket_worker_assignments \ + (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at) \ + VALUES ('workspace-b', 'ticket-b', 'assignment-b', 'runtime-b', \ + '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')", + [], + ) + .unwrap(); + let mismatched_current_assignment = conn.execute( + "INSERT INTO ticket_current_worker_assignments \ + (workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at) \ + VALUES ('workspace-b', 'ticket-b', 'assignment-b', 'runtime-a', \ + '00000000-0000-7000-8000-000000000001', '2026-01-01')", + [], + ); + assert!(mismatched_current_assignment.is_err()); + let cross_assignment_event = conn.execute( + "INSERT INTO ticket_worker_assignment_events \ + (workspace_id, ticket_id, event_id, action, assignment_id, actor, created_at) \ + VALUES ('workspace-a', 'ticket-a', 'event-cross-assignment', 'assigned', \ + 'assignment-b', 'tester', '2026-01-01')", + [], + ); + assert!(cross_assignment_event.is_err()); + let cross_operation_ticket = conn.execute( + "INSERT INTO ticket_assignment_operations \ + (workspace_id, operation_id, action, ticket_id, created_at) \ + VALUES ('workspace-b', 'operation-cross-ticket', 'assign', 'ticket-a', '2026-01-01')", + [], + ); + assert!(cross_operation_ticket.is_err()); assert!( conn.execute( "DELETE FROM repositories WHERE workspace_id = 'workspace-a' AND repository_id = 'repo-a'", @@ -8321,6 +8567,26 @@ INSERT INTO objective_ticket_links ( .unwrap(), 0 ); + conn.execute( + "DELETE FROM worker_registry WHERE workspace_id = 'workspace-b' AND worker_id = '00000000-0000-7000-8000-000000000002'", + [], + ) + .unwrap(); + conn.execute( + "DELETE FROM typed_tickets WHERE workspace_id = 'workspace-b' AND ticket_id = 'ticket-b'", + [], + ) + .unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id = 'workspace-b'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 1, + "historical assignments survive Worker and Ticket retention deletion" + ); conn.execute( "DELETE FROM workspaces WHERE workspace_id = 'workspace-b'", [], @@ -8335,6 +8601,15 @@ INSERT INTO objective_ticket_links ( .unwrap(), 0 ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id = 'workspace-b'", + [], + |row| row.get::<_, i64>(0), + ) + .unwrap(), + 0 + ); let foreign_key_failures: i64 = conn .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { diff --git a/docs/development/workspace-schema-migrations.md b/docs/development/workspace-schema-migrations.md index 5e878942..e9e2caeb 100644 --- a/docs/development/workspace-schema-migrations.md +++ b/docs/development/workspace-schema-migrations.md @@ -20,7 +20,8 @@ The Workspace Server owns one control-plane SQLite database. Schema changes are Start exactly one instance of the new Server binary against the database. Startup applies migration 39 in one SQLite transaction after the Ticket and Merge Request component schemas are available. The migration: - rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity; -- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and assignment references; +- adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references; +- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; reservation operation ids remain intentionally unconstrained until their resources exist; - checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and - restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back. From 6945b7b3c34e7acb744eb6992e0a95a224f76cc7 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 11:14:33 +0900 Subject: [PATCH 06/22] fix: bind assignment events to tickets --- crates/workspace-server/src/store.rs | 36 +++++++++++++++++++++------- 1 file changed, 27 insertions(+), 9 deletions(-) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 8069d018..a66e4e80 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -5271,10 +5271,12 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result Date: Thu, 20 Aug 2026 11:19:02 +0900 Subject: [PATCH 07/22] test: complete ticket reference fixture --- crates/worker/src/feature/builtin/ticket.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 502e34d7..60acbf5e 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -1781,6 +1781,7 @@ provider = "github" assert!(request.contains("\"title\":\"HTTP ticket\"")); let response_body = serde_json::to_string(&TicketRef { id: "01TEST".to_string(), + human_key: None, slug: "http-ticket".to_string(), status: ticket::TicketStatus::Open, }) From 3e833b529569be5ada51d060012663c99ceedebf Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 11:19:02 +0900 Subject: [PATCH 08/22] fix: finalize stopped subworker sessions --- crates/protocol/src/lib.rs | 29 ++ crates/tools/src/edit.rs | 13 +- crates/tools/src/lib.rs | 2 +- crates/tools/src/tracker.rs | 92 +++++- crates/tools/src/write.rs | 3 + crates/tui/src/app.rs | 113 ++++++- .../src/feature/builtin/manage_worker.rs | 96 +++++- crates/worker/src/internal_worker.rs | 35 +- crates/worker/src/spawn/comm_tools.rs | 29 +- crates/worker/src/spawn/registry.rs | 304 +++++++++++++++++- crates/worker/src/spawn/tool.rs | 2 + web/workspace/src/lib/generated/protocol.ts | 2 +- .../src/lib/workspace/console/model.test.ts | 108 ++++++- .../src/lib/workspace/console/model.ts | 25 ++ 14 files changed, 816 insertions(+), 37 deletions(-) diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index d44427cf..19e73b50 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -530,6 +530,15 @@ pub enum Event { revision: u64, event: Box, }, + /// Terminal removal fence for one parent-owned Internal Worker session. + /// + /// Clients discard the matching child and descendants, then ignore later + /// nested events for this identity until an authoritative snapshot replaces + /// the projection. + InternalWorkerRemoved { + worker: InternalWorkerRef, + revision: u64, + }, /// Server-side segment log rotated to a fresh `SegmentStart`. /// /// Fires on compaction and on auto-fork when the store head drifts @@ -1802,6 +1811,26 @@ mod tests { } } + #[test] + fn internal_worker_removal_roundtrip_preserves_terminal_fence() { + let event = Event::InternalWorkerRemoved { + worker: InternalWorkerRef { + session_id: "session-1".into(), + name: "research".into(), + parent_session_id: Some("parent-session".into()), + kind: InternalWorkerKind::SubWorker, + }, + revision: 8, + }; + let json = serde_json::to_string(&event).unwrap(); + let decoded: Event = serde_json::from_str(&json).unwrap(); + assert!(matches!( + decoded, + Event::InternalWorkerRemoved { worker, revision } + if worker.session_id == "session-1" && revision == 8 + )); + } + #[test] fn legacy_snapshot_defaults_internal_workers_to_empty() { let snapshot: Event = serde_json::from_value(serde_json::json!({ diff --git a/crates/tools/src/edit.rs b/crates/tools/src/edit.rs index a6a4869a..17e87dae 100644 --- a/crates/tools/src/edit.rs +++ b/crates/tools/src/edit.rs @@ -72,13 +72,20 @@ impl Tool for EditTool { }) .await .map_err(ToolsError::from)?; - self.tracker.record_workdir_hash(&path, result.content_hash); + let replacements = result.replacements; + self.tracker.record_workdir_edit( + &path, + result.content_hash, + replacements, + params.new_string.lines().count(), + params.old_string.lines().count(), + ); let summary = format!( "Edited {} ({} replacement{})", path, - result.replacements, - if result.replacements == 1 { "" } else { "s" } + replacements, + if replacements == 1 { "" } else { "s" } ); let preview = make_preview(¶ms.new_string, ¶ms.new_string); diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index 319ee71f..df140220 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -27,7 +27,7 @@ pub use error::ToolsError; pub use glob::glob_tool; pub use grep::grep_tool; pub use read::read_tool; -pub use tracker::Tracker; +pub use tracker::{ChangeStat, Tracker}; pub use view_image::view_image_tool; pub use web::{web_fetch_tool, web_search_tool}; pub use write::write_tool; diff --git a/crates/tools/src/tracker.rs b/crates/tools/src/tracker.rs index 40a771be..5c284bcb 100644 --- a/crates/tools/src/tracker.rs +++ b/crates/tools/src/tracker.rs @@ -119,12 +119,22 @@ fn normalize_path_lexically(path: &Path) -> PathBuf { normalized } +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)] +pub struct ChangeStat { + pub added: u64, + pub deleted: u64, +} + #[derive(Debug, Default)] struct Inner { /// Hash of each file's last observed contents, keyed by canonical path. hashes: HashMap, + /// Line count paired with observations that included the file content. + line_counts: HashMap, /// LRU list of touched files. Front = most recently touched. recency: VecDeque, + /// Successful Write/Edit mutations attributed to this session's tools. + change_stat: ChangeStat, } /// Canonical-path keyed tracker of file observations and their recency. @@ -187,8 +197,27 @@ impl Tracker { } } - pub fn record_workdir_content(&self, path: &workdir::WorkdirPath, bytes: &[u8]) { - self.record_workdir_hash(path, hash_bytes(bytes)); + pub fn record_workdir_content(&self, path: &workdir::WorkdirPath, content: &[u8]) { + let key = PathBuf::from(path.as_str()); + let hash = hash_bytes(content); + let line_count = String::from_utf8_lossy(content).lines().count(); + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.line_counts.insert(key.clone(), line_count); + inner.hashes.insert(key.clone(), hash); + inner.recency.retain(|candidate| candidate != &key); + inner.recency.push_front(key); + if inner.recency.len() > RECENCY_CAPACITY { + inner.recency.pop_back(); + } + } + + pub fn observed_workdir_line_count(&self, path: &workdir::WorkdirPath) -> Option { + self.inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .line_counts + .get(Path::new(path.as_str())) + .copied() } pub fn record_workdir_hash(&self, path: &workdir::WorkdirPath, hash: workdir::ContentHash) { @@ -202,6 +231,50 @@ impl Tracker { } } + /// Record a successful, session-attributable source mutation. + /// + /// Callers supply line counts derived from the exact replacement accepted + /// by a Write/Edit tool. Bash and external process mutations are excluded + /// because this tracker cannot attribute them to one tool operation + /// authoritatively. + pub fn record_change(&self, added: usize, deleted: usize) { + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.change_stat.added = inner.change_stat.added.saturating_add(added as u64); + inner.change_stat.deleted = inner.change_stat.deleted.saturating_add(deleted as u64); + } + + pub fn record_workdir_edit( + &self, + path: &workdir::WorkdirPath, + hash: workdir::ContentHash, + replacements: usize, + added_lines_per_replacement: usize, + deleted_lines_per_replacement: usize, + ) { + let added = added_lines_per_replacement.saturating_mul(replacements); + let deleted = deleted_lines_per_replacement.saturating_mul(replacements); + let key = PathBuf::from(path.as_str()); + let mut inner = self.inner.lock().unwrap_or_else(|e| e.into_inner()); + inner.change_stat.added = inner.change_stat.added.saturating_add(added as u64); + inner.change_stat.deleted = inner.change_stat.deleted.saturating_add(deleted as u64); + if let Some(line_count) = inner.line_counts.get_mut(&key) { + *line_count = line_count.saturating_sub(deleted).saturating_add(added); + } + inner.hashes.insert(key.clone(), hash); + inner.recency.retain(|candidate| candidate != &key); + inner.recency.push_front(key); + if inner.recency.len() > RECENCY_CAPACITY { + inner.recency.pop_back(); + } + } + + pub fn change_stat(&self) -> ChangeStat { + self.inner + .lock() + .unwrap_or_else(|e| e.into_inner()) + .change_stat + } + pub fn expected_workdir_hash( &self, path: &workdir::WorkdirPath, @@ -458,6 +531,21 @@ mod tests { } } + #[test] + fn change_stat_saturates_and_accumulates_tracked_mutations() { + let tracker = Tracker::new(); + tracker.record_change(7, 3); + tracker.record_change(5, 2); + + assert_eq!( + tracker.change_stat(), + ChangeStat { + added: 12, + deleted: 5, + } + ); + } + #[tokio::test] async fn mutation_guard_blocks_equivalent_paths_until_drop() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/tools/src/write.rs b/crates/tools/src/write.rs index cec2ad05..ff9ff09d 100644 --- a/crates/tools/src/write.rs +++ b/crates/tools/src/write.rs @@ -50,6 +50,7 @@ impl Tool for WriteTool { Err(error) => return Err(ToolsError::from(error).into()), }; + let old_line_count = self.tracker.observed_workdir_line_count(&path).unwrap_or(0); let outcome = self .session .write(WriteRequest { @@ -60,6 +61,8 @@ impl Tool for WriteTool { .await .map_err(ToolsError::from)?; + self.tracker + .record_change(params.content.lines().count(), old_line_count); self.tracker .record_workdir_content(&path, params.content.as_bytes()); diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 473b7a91..a742b1bd 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,4 +1,4 @@ -use std::collections::VecDeque; +use std::collections::{HashMap, VecDeque}; use std::path::Path; use std::time::{Duration, Instant}; @@ -283,6 +283,8 @@ pub struct App { /// Presentation-only Internal Worker projections keyed by session identity. /// They are rendered in separate sub-panes and never mixed into `blocks`. pub internal_workers: Vec, + /// Terminal child-session fences, reset only by an authoritative snapshot. + removed_internal_workers: HashMap, pub scroll: Scroll, pub mode: Mode, pub cache: FileCache, @@ -361,6 +363,7 @@ impl App { blocks: Vec::new(), run_error_messages: Vec::new(), internal_workers: Vec::new(), + removed_internal_workers: HashMap::new(), scroll: Scroll::default(), mode: Mode::Normal, cache: FileCache::new(), @@ -1318,6 +1321,9 @@ impl App { revision, event, } => self.apply_internal_worker_event(worker, revision, *event), + Event::InternalWorkerRemoved { worker, revision } => { + self.remove_internal_worker(worker, revision) + } Event::Status { status } => { self.rewind_refresh_fence = false; self.set_worker_status(status); @@ -2002,6 +2008,7 @@ impl App { .into_iter() .map(Self::internal_worker_view_from_snapshot) .collect(); + self.removed_internal_workers.clear(); } fn internal_worker_view_from_snapshot(snapshot: InternalWorkerSnapshot) -> InternalWorkerView { @@ -2029,6 +2036,12 @@ impl App { revision: u64, event: Event, ) { + if self + .removed_internal_workers + .contains_key(&worker.session_id) + { + return; + } let index = self .internal_workers .iter() @@ -2051,6 +2064,26 @@ impl App { let _ = target.app.handle_worker_event(event); } + fn remove_internal_worker(&mut self, worker: InternalWorkerRef, revision: u64) { + let Some(index) = self + .internal_workers + .iter() + .position(|candidate| candidate.worker.session_id == worker.session_id) + else { + self.removed_internal_workers + .entry(worker.session_id) + .and_modify(|current| *current = (*current).max(revision)) + .or_insert(revision); + return; + }; + if revision <= self.internal_workers[index].revision { + return; + } + self.internal_workers.remove(index); + self.removed_internal_workers + .insert(worker.session_id, revision); + } + fn restore_snapshot( &mut self, entries: &[serde_json::Value], @@ -3542,6 +3575,84 @@ mod completion_flow_tests { ); } + #[test] + fn terminal_internal_worker_removal_drops_descendants_and_fences_late_events() { + let mut app = App::new("parent".into()); + let worker = InternalWorkerRef { + session_id: "child-session".into(), + name: "child".into(), + parent_session_id: Some("parent-session".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }; + let nested = InternalWorkerRef { + session_id: "grandchild-session".into(), + name: "grandchild".into(), + parent_session_id: Some("child-session".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }; + app.handle_worker_event(Event::InternalWorker { + worker: worker.clone(), + revision: 2, + event: Box::new(Event::InternalWorker { + worker: nested, + revision: 1, + event: Box::new(Event::TextDone { + text: "nested".into(), + }), + }), + }); + assert_eq!(app.internal_workers.len(), 1); + assert_eq!(app.internal_workers[0].app.internal_workers.len(), 1); + + app.handle_worker_event(Event::InternalWorkerRemoved { + worker: worker.clone(), + revision: 3, + }); + app.handle_worker_event(Event::InternalWorker { + worker, + revision: 4, + event: Box::new(Event::TextDone { + text: "late".into(), + }), + }); + + assert!(app.internal_workers.is_empty()); + app.handle_worker_event(Event::Snapshot { + greeting: test_greeting(), + entries: Vec::new(), + status: WorkerStatus::Idle, + in_flight: Default::default(), + internal_workers: Vec::new(), + }); + assert!(app.internal_workers.is_empty()); + assert!(app.removed_internal_workers.is_empty()); + } + + #[test] + fn stale_internal_worker_removal_keeps_newer_projection() { + let mut app = App::new("parent".into()); + let worker = InternalWorkerRef { + session_id: "child-session".into(), + name: "child".into(), + parent_session_id: Some("parent-session".into()), + kind: protocol::InternalWorkerKind::SubWorker, + }; + app.handle_worker_event(Event::InternalWorker { + worker: worker.clone(), + revision: 4, + event: Box::new(Event::TextDone { + text: "current".into(), + }), + }); + app.handle_worker_event(Event::InternalWorkerRemoved { + worker, + revision: 3, + }); + + assert_eq!(app.internal_workers.len(), 1); + assert_eq!(app.internal_workers[0].revision, 4); + } + #[test] fn snapshot_authoritatively_replaces_internal_worker_views() { let mut app = App::new("parent".into()); diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index 54b97524..c48383a7 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -16,7 +16,7 @@ use crate::feature::{ FeatureDescriptor, FeatureInstallContext, FeatureInstallError, FeatureModule, ServiceDeclaration, ServiceId, ToolContribution, ToolDeclaration, }; -use crate::spawn::registry::SpawnedWorkerRegistry; +use crate::spawn::registry::{SpawnedWorkerRegistry, SubWorkerStopSummary}; use crate::worker::{ WorkspaceClient, WorkspaceClientError, WorkspaceRequest, WorkspaceRequestMethod, WorkspaceResponse, @@ -138,14 +138,19 @@ impl WorkerControlService for WorkspaceWorkerControlService { let registry = self.registry.as_ref().ok_or_else(|| { WorkspaceClientError::Request("unknown Worker or permission not granted".to_string()) })?; - registry + let summary = registry .remove_internal(name) .await - .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; + .map_err(|error| WorkspaceClientError::Request(error.to_string()))? + .ok_or_else(|| { + WorkspaceClientError::Request( + "unknown Worker or permission not granted".to_string(), + ) + })?; Ok(WorkspaceResponse { status: 200, - body: serde_json::json!({ "subject": { "kind": "sub_worker", "name": name } }) - .to_string(), + body: serde_json::to_string(&summary) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?, }) } @@ -839,6 +844,15 @@ fn tool_output( response.status, response.body ))); } + if operation == WorkerOperation::Stop + && let Ok(summary) = serde_json::from_str::(&response.body) + { + return Ok(ToolOutput { + summary: render_subworker_stop_summary(&summary), + content: Some(response.body), + attachments: Vec::new(), + }); + } Ok(ToolOutput { summary: format!("{} completed", operation.tool_name()), content: Some(response.body), @@ -846,6 +860,37 @@ fn tool_output( }) } +fn render_subworker_stop_summary(summary: &SubWorkerStopSummary) -> String { + let tools = if summary.tool_counts.is_empty() { + "No tool calls".to_string() + } else { + summary + .tool_counts + .iter() + .map(|tool| format!("{} {}", tool.count, tool.name)) + .collect::>() + .join(", ") + }; + let elapsed = format_elapsed(summary.elapsed_ms); + let changes = summary + .change_stat + .as_ref() + .map(|stat| format!("+{}/-{} Changes · ", stat.added, stat.deleted)) + .unwrap_or_default(); + format!("SubWorkerStop - done\n {tools}\n {changes}{elapsed}",) +} + +fn format_elapsed(elapsed_ms: u64) -> String { + let seconds = elapsed_ms / 1_000; + let minutes = seconds / 60; + let seconds = seconds % 60; + if minutes > 0 { + format!("{minutes}m {seconds}s") + } else { + format!("{seconds}s") + } +} + fn definition( operation: WorkerOperation, control: Arc, @@ -1252,6 +1297,47 @@ mod tests { assert!(client.removals.lock().unwrap().is_empty()); } + #[test] + fn subworker_stop_output_is_compact_and_keeps_typed_evidence() { + let summary = SubWorkerStopSummary { + session_id: "session-1".to_string(), + display_name: "research".to_string(), + outcome: crate::spawn::registry::SubWorkerFinalOutcome::Done, + elapsed_ms: 78_000, + tool_counts: vec![ + crate::spawn::registry::SubWorkerToolCount { + name: "Read".to_string(), + count: 26, + }, + crate::spawn::registry::SubWorkerToolCount { + name: "Grep".to_string(), + count: 5, + }, + ], + change_stat: Some(crate::spawn::registry::SubWorkerChangeStat { + added: 215, + deleted: 148, + source: "tracked_write_edit_tools".to_string(), + }), + }; + let response = WorkspaceResponse { + status: 200, + body: serde_json::to_string(&summary).unwrap(), + }; + + let output = tool_output(WorkerOperation::Stop, response).unwrap(); + + assert_eq!( + output.summary, + "SubWorkerStop - done\n 26 Read, 5 Grep\n +215/-148 Changes · 1m 18s" + ); + assert_eq!( + serde_json::from_str::(output.content.as_deref().unwrap()) + .unwrap(), + summary + ); + } + #[test] fn worker_inputs_reject_paths_and_parent_traversal() { assert!(authority_id("https://runtime.example", "runtime_id").is_err()); diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 0a21d0d2..9b00dc19 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -294,6 +294,8 @@ pub(crate) struct InternalWorkerSessionHandle { last_error: Arc>>, child_registry: Option>, sink: SegmentLogSink, + #[cfg(test)] + fail_stop: Arc, } impl InternalWorkerSessionHandle { @@ -319,6 +321,9 @@ impl InternalWorkerSessionHandle { #[cfg(test)] pub(crate) fn publish_test_entry(&self, entry: LogEntry) { + self.store + .append(self.session_id, self.segment_id, &entry) + .expect("append test Internal Worker entry"); self.sink.publish(entry); } @@ -419,7 +424,23 @@ impl InternalWorkerSessionHandle { } } + #[cfg(test)] + pub(crate) fn force_status(&self, status: InternalWorkerSessionStatus) { + self.status + .store(status.encode(), std::sync::atomic::Ordering::Release); + } + + #[cfg(test)] + pub(crate) fn force_stop_failure(&self) { + self.fail_stop + .store(true, std::sync::atomic::Ordering::Release); + } + pub(crate) async fn stop(&self) -> Result<(), InternalWorkerSessionError> { + #[cfg(test)] + if self.fail_stop.load(std::sync::atomic::Ordering::Acquire) { + return Err(InternalWorkerSessionError::Unavailable); + } let prior = self.status.swap( InternalWorkerSessionStatus::Stopping.encode(), std::sync::atomic::Ordering::AcqRel, @@ -582,6 +603,8 @@ pub(crate) async fn prepare_internal_worker_session( last_error: last_error.clone(), child_registry, sink, + #[cfg(test)] + fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; tokio::spawn(async move { @@ -890,8 +913,17 @@ pub(crate) fn test_internal_worker_session( let session_id = session_store::new_session_id(); let segment_id = session_store::new_segment_id(); let (command_tx, mut command_rx) = tokio::sync::mpsc::channel(1); - tokio::spawn(async move { while command_rx.recv().await.is_some() {} }); let (event_tx, _) = broadcast::channel(256); + let command_event_tx = event_tx.clone(); + tokio::spawn(async move { + while let Some(command) = command_rx.recv().await { + if let InternalWorkerSessionCommand::Stop(done_tx) = command { + let _ = command_event_tx.send(Event::Shutdown); + let _ = done_tx.send(()); + break; + } + } + }); let sink = SegmentLogSink::new(); spawn_internal_log_event_bridge(sink.clone(), event_tx.clone()); let handle = InternalWorkerSessionHandle { @@ -909,6 +941,7 @@ pub(crate) fn test_internal_worker_session( last_error: Arc::new(Mutex::new(None)), child_registry: None, sink, + fail_stop: Arc::new(std::sync::atomic::AtomicBool::new(false)), }; (handle, event_tx) } diff --git a/crates/worker/src/spawn/comm_tools.rs b/crates/worker/src/spawn/comm_tools.rs index c0fd4d55..ca1d16a1 100644 --- a/crates/worker/src/spawn/comm_tools.rs +++ b/crates/worker/src/spawn/comm_tools.rs @@ -170,20 +170,27 @@ impl Tool for SubWorkerStopTool { ) -> Result { let input: NameInput = serde_json::from_str(input_json) .map_err(|e| ToolError::InvalidArgument(format!("invalid SubWorkerStop input: {e}")))?; - if let Some(record) = self.registry.get_internal(&input.name) { - record.session.stop().await.map_err(|error| { - ToolError::ExecutionFailed(format!("stop `{}`: {error}", input.name)) - })?; - self.registry - .remove_internal(&input.name) - .await - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if let Some(summary) = self + .registry + .remove_internal(&input.name) + .await + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))? + { return Ok(ToolOutput { summary: format!( - "stopped worker `{}` and reclaimed delegated scope", - input.name + "SubWorkerStop - done\n {} tool kind{}\n {}ms", + summary.tool_counts.len(), + if summary.tool_counts.len() == 1 { + "" + } else { + "s" + }, + summary.elapsed_ms, + ), + content: Some( + serde_json::to_string(&summary) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?, ), - content: None, attachments: Vec::new(), }); } diff --git a/crates/worker/src/spawn/registry.rs b/crates/worker/src/spawn/registry.rs index 0f9042cf..c800b01d 100644 --- a/crates/worker/src/spawn/registry.rs +++ b/crates/worker/src/spawn/registry.rs @@ -7,17 +7,20 @@ //! Parent registry drop closes all session handles and synchronously returns delegated Write deny //! rules to the parent scope. -use std::collections::HashSet; +use std::collections::{BTreeMap, HashSet}; use std::io; use std::sync::{ Arc, Mutex, atomic::{AtomicBool, AtomicU64, Ordering}, }; +use std::time::Instant; + +use serde::{Deserialize, Serialize}; use manifest::{Permission, ScopeRule, SharedScope}; use protocol::{Event, InternalWorkerKind, InternalWorkerRef, InternalWorkerSnapshot}; use session_store::{ - WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError, + LoggedItem, WorkerMetadataStore, WorkerReclaimedChild, WorkerSpawnedChild, WorkerStoreError, }; use tokio::sync::broadcast; use tracing::warn; @@ -27,6 +30,39 @@ use crate::internal_worker::{InternalWorkerSessionHandle, InternalWorkerVisibili use crate::runtime::dir::{RuntimeDir, SpawnedWorkerRecord}; use crate::runtime::worker_allocation; +const STOP_SUMMARY_TOOL_LIMIT: usize = 16; +const STOP_SUMMARY_TOOL_NAME_LIMIT: usize = 64; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +pub(crate) enum SubWorkerFinalOutcome { + Done, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SubWorkerToolCount { + pub name: String, + pub count: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SubWorkerChangeStat { + pub added: u64, + pub deleted: u64, + pub source: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub(crate) struct SubWorkerStopSummary { + pub session_id: String, + pub display_name: String, + pub outcome: SubWorkerFinalOutcome, + pub elapsed_ms: u64, + pub tool_counts: Vec, + #[serde(skip_serializing_if = "Option::is_none")] + pub change_stat: Option, +} + #[derive(Clone)] pub(crate) struct InternalSpawnedWorkerRecord { pub worker_name: String, @@ -35,8 +71,13 @@ pub(crate) struct InternalSpawnedWorkerRecord { #[cfg(test)] pub installed_tools: Arc<[String]>, pub session: InternalWorkerSessionHandle, + change_tracker: Option, + started_at: Instant, + stop_lock: Arc>, scope_reclaimed: Arc, protocol_revision: Arc, + protocol_emit_lock: Arc>, + protocol_terminal: Arc, forwarding_started: Arc, } @@ -47,6 +88,7 @@ impl InternalSpawnedWorkerRecord { workdir_delegation: WorkdirDelegation, #[cfg(test)] installed_tools: Vec, session: InternalWorkerSessionHandle, + change_tracker: Option, ) -> Self { Self { worker_name, @@ -55,12 +97,64 @@ impl InternalSpawnedWorkerRecord { #[cfg(test)] installed_tools: installed_tools.into(), session, + change_tracker, + started_at: Instant::now(), + stop_lock: Arc::new(tokio::sync::Mutex::new(())), scope_reclaimed: Arc::new(AtomicBool::new(false)), protocol_revision: Arc::new(AtomicU64::new(0)), + protocol_emit_lock: Arc::new(Mutex::new(())), + protocol_terminal: Arc::new(AtomicBool::new(false)), forwarding_started: Arc::new(AtomicBool::new(false)), } } + fn stop_summary(&self) -> SubWorkerStopSummary { + let mut counts = BTreeMap::::new(); + for entry in self.session.entries() { + if let session_store::LogEntry::AssistantItem { + item: LoggedItem::ToolCall { name, .. }, + .. + } = entry + { + let count = counts.entry(bounded_tool_name(&name)).or_default(); + *count = count.saturating_add(1); + } + } + let mut tool_counts = counts + .into_iter() + .map(|(name, count)| SubWorkerToolCount { name, count }) + .collect::>(); + tool_counts.sort_by(|left, right| { + right + .count + .cmp(&left.count) + .then_with(|| left.name.cmp(&right.name)) + }); + tool_counts.truncate(STOP_SUMMARY_TOOL_LIMIT); + + let change_stat = self.change_tracker.as_ref().and_then(|tracker| { + let stat = tracker.change_stat(); + (stat.added > 0 || stat.deleted > 0).then(|| SubWorkerChangeStat { + added: stat.added, + deleted: stat.deleted, + source: "tracked_write_edit_tools".to_string(), + }) + }); + + SubWorkerStopSummary { + session_id: self.session.session_id_string(), + display_name: self.worker_name.clone(), + outcome: SubWorkerFinalOutcome::Done, + elapsed_ms: self + .started_at + .elapsed() + .as_millis() + .min(u128::from(u64::MAX)) as u64, + tool_counts, + change_stat, + } + } + fn claim_scope_reclaim(&self) -> bool { !self.scope_reclaimed.swap(true, Ordering::AcqRel) } @@ -277,12 +371,20 @@ impl SpawnedWorkerRegistry { }; let worker = record.protocol_ref(Some(parent_session_id)); let protocol_revision = record.protocol_revision.clone(); + let protocol_emit_lock = record.protocol_emit_lock.clone(); + let protocol_terminal = record.protocol_terminal.clone(); let mut child_rx = record.session.subscribe_events(); tokio::spawn(async move { loop { match child_rx.recv().await { Ok(event) => { let shutdown = matches!(event, Event::Shutdown); + let _emit_guard = protocol_emit_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + if protocol_terminal.load(Ordering::Acquire) { + break; + } let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; let _ = parent_tx.send(Event::InternalWorker { worker: worker.clone(), @@ -294,6 +396,12 @@ impl SpawnedWorkerRegistry { } } Err(broadcast::error::RecvError::Lagged(skipped)) => { + let _emit_guard = protocol_emit_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + if protocol_terminal.load(Ordering::Acquire) { + break; + } let revision = protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; let _ = parent_tx.send(Event::InternalWorker { worker: worker.clone(), @@ -385,13 +493,34 @@ impl SpawnedWorkerRegistry { result } + /// Stop one direct Internal SubWorker and discard its registry/scope state. + /// + /// The child actor must acknowledge its stop before the registry is removed. + /// After scope reclamation and removal, `InternalWorkerRemoved` is published + /// exactly once as the parent-stream terminal fence. Callers only receive + /// `Done` after all authoritative cleanup succeeds. pub(crate) async fn remove_internal( &self, worker_name: &str, - ) -> io::Result> { - if let Some(record) = self.get_internal(worker_name) { - self.reclaim_record_scope(&record)?; + ) -> io::Result> { + let Some(record) = self.get_internal(worker_name) else { + return Ok(None); + }; + let _stop_guard = record.stop_lock.lock().await; + let still_registered = self.get_internal(worker_name).is_some_and(|current| { + current.session.session_id_string() == record.session.session_id_string() + }); + if !still_registered { + return Ok(None); } + + record + .session + .stop() + .await + .map_err(|error| io::Error::other(error.to_string()))?; + let summary = record.stop_summary(); + self.reclaim_record_scope(&record)?; let removed = { let mut records = self.internal_records.lock().map_err(|_| { @@ -402,14 +531,41 @@ impl SpawnedWorkerRegistry { })?; let removed = records .iter() - .position(|record| record.worker_name == worker_name) + .position(|candidate| { + candidate.worker_name == worker_name + && candidate.session.session_id_string() + == record.session.session_id_string() + }) .map(|index| records.remove(index)); if removed.is_some() { names.remove(worker_name); } removed }; - Ok(removed) + if removed.is_some() { + self.publish_internal_removal(&record); + } + Ok(removed.map(|_| summary)) + } + + fn publish_internal_removal(&self, record: &InternalSpawnedWorkerRecord) { + if record.session.visibility() != InternalWorkerVisibility::ParentClient { + return; + } + let Some((parent_tx, parent_session_id)) = self.parent_protocol.lock().unwrap().clone() + else { + return; + }; + let _emit_guard = record + .protocol_emit_lock + .lock() + .unwrap_or_else(|error| error.into_inner()); + record.protocol_terminal.store(true, Ordering::Release); + let revision = record.protocol_revision.fetch_add(1, Ordering::AcqRel) + 1; + let _ = parent_tx.send(Event::InternalWorkerRemoved { + worker: record.protocol_ref(Some(parent_session_id)), + revision, + }); } } @@ -508,6 +664,17 @@ fn record_from_worker_state(child: &WorkerSpawnedChild) -> io::Result String { + let mut bounded = name + .chars() + .take(STOP_SUMMARY_TOOL_NAME_LIMIT) + .collect::(); + if name.chars().count() > STOP_SUMMARY_TOOL_NAME_LIMIT { + bounded.push('…'); + } + bounded +} + fn store_error_to_io(error: WorkerStoreError) -> io::Error { io::Error::other(error) } @@ -520,7 +687,7 @@ mod tests { use session_store::LogEntry; use super::*; - use crate::internal_worker::test_internal_worker_session; + use crate::internal_worker::{InternalWorkerSessionStatus, test_internal_worker_session}; fn registry() -> Arc { let scope = Scope::from_config(&ScopeConfig { @@ -577,6 +744,7 @@ mod tests { delegation, Vec::new(), session, + None, ), sender, ) @@ -669,4 +837,124 @@ mod tests { ); assert!(registry.internal_worker_snapshots().is_empty()); } + + fn install_record(registry: &SpawnedWorkerRegistry, record: InternalSpawnedWorkerRecord) { + registry + .internal_names + .lock() + .unwrap() + .insert(record.worker_name.clone()); + registry.internal_records.lock().unwrap().push(record); + } + + #[tokio::test] + async fn stop_removes_internal_worker_and_returns_bounded_summary() { + let registry = registry(); + let (parent_tx, mut parent_rx) = broadcast::channel(32); + registry.attach_parent_protocol(parent_tx, "parent-session".into()); + let tracker = tools::Tracker::new(); + tracker.record_change(12, 4); + let (mut record, _events) = record("child", InternalWorkerVisibility::ParentClient).await; + record.change_tracker = Some(tracker); + for (index, name) in ["Read", "Read", "Grep"].into_iter().enumerate() { + record.session.publish_test_entry(LogEntry::AssistantItem { + ts: index as u64, + item: LoggedItem::ToolCall { + call_id: format!("call-{index}"), + name: name.to_string(), + arguments: "{}".to_string(), + }, + }); + } + registry.start_protocol_forwarding(record.clone()); + install_record(®istry, record); + + let summary = registry.remove_internal("child").await.unwrap().unwrap(); + + assert_eq!(summary.display_name, "child"); + assert_eq!(summary.outcome, SubWorkerFinalOutcome::Done); + assert_eq!( + summary.tool_counts, + vec![ + SubWorkerToolCount { + name: "Read".to_string(), + count: 2, + }, + SubWorkerToolCount { + name: "Grep".to_string(), + count: 1, + }, + ] + ); + assert_eq!( + summary.change_stat, + Some(SubWorkerChangeStat { + added: 12, + deleted: 4, + source: "tracked_write_edit_tools".to_string(), + }) + ); + assert!(registry.get_internal("child").is_none()); + let terminal_revision = loop { + if let Event::InternalWorkerRemoved { worker, revision } = + parent_rx.recv().await.unwrap() + { + assert_eq!(worker.session_id, summary.session_id); + assert!(revision > 0); + break revision; + } + }; + assert!(registry.remove_internal("child").await.unwrap().is_none()); + while let Ok(Ok(event)) = + tokio::time::timeout(Duration::from_millis(20), parent_rx.recv()).await + { + assert!(!matches!(event, Event::InternalWorkerRemoved { .. })); + if let Event::InternalWorker { revision, .. } = event { + assert!(revision > terminal_revision); + } + } + } + + #[tokio::test] + async fn running_worker_is_stopped_before_removal() { + let registry = registry(); + let (record, _events) = record("running", InternalWorkerVisibility::ParentClient).await; + record + .session + .force_status(InternalWorkerSessionStatus::Running); + install_record(®istry, record); + + let summary = registry.remove_internal("running").await.unwrap().unwrap(); + + assert_eq!(summary.outcome, SubWorkerFinalOutcome::Done); + assert!(registry.get_internal("running").is_none()); + } + + #[tokio::test] + async fn stop_failure_keeps_registry_and_emits_no_removal() { + let registry = registry(); + let (parent_tx, mut parent_rx) = broadcast::channel(8); + registry.attach_parent_protocol(parent_tx, "parent-session".into()); + let (record, _events) = record("child", InternalWorkerVisibility::ParentClient).await; + record.session.force_stop_failure(); + install_record(®istry, record); + + let error = registry.remove_internal("child").await.unwrap_err(); + + assert!(error.to_string().contains("unavailable")); + assert!(registry.get_internal("child").is_some()); + assert!(matches!( + parent_rx.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + } + + #[tokio::test] + async fn read_only_summary_omits_unavailable_change_stat() { + let tracker = tools::Tracker::new(); + let (mut record, _events) = record("reader", InternalWorkerVisibility::ParentClient).await; + record.change_tracker = Some(tracker); + + assert_eq!(record.stop_summary().change_stat, None); + } } diff --git a/crates/worker/src/spawn/tool.rs b/crates/worker/src/spawn/tool.rs index b4c117cd..41e0866c 100644 --- a/crates/worker/src/spawn/tool.rs +++ b/crates/worker/src/spawn/tool.rs @@ -481,6 +481,7 @@ impl Tool for SubWorkerSpawnTool { .map_err(|error| { ToolError::ExecutionFailed(format!("install Internal Worker features: {error}")) })?; + let child_change_tracker = child.tracker().cloned(); #[cfg(test)] let installed_tools = child .engine() @@ -587,6 +588,7 @@ impl Tool for SubWorkerSpawnTool { #[cfg(test)] installed_tools, session.clone(), + child_change_tracker, ); if let Err(error) = name_reservation.commit(record) { let _ = session.stop().await; diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 471f4dab..cf8b298c 100644 --- a/web/workspace/src/lib/generated/protocol.ts +++ b/web/workspace/src/lib/generated/protocol.ts @@ -178,4 +178,4 @@ in_flight?: InFlightSnapshot, * Parent-owned Internal Worker sessions visible to this client. * Service-private Internal Workers are deliberately excluded. */ -internal_workers?: Array, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array, } } | { "event": "rewind_applied", "data": { entries: Array, input: Array, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" }; +internal_workers?: Array, } } | { "event": "internal_worker", "data": { worker: InternalWorkerRef, revision: number, event: Event, } } | { "event": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "event": "segment_rotated", "data": { entry: unknown, } } | { "event": "status", "data": { status: WorkerStatus, } } | { "event": "completions", "data": { kind: CompletionKind, entries: Array, } } | { "event": "rewind_targets", "data": { head_entries: number, targets: Array, } } | { "event": "rewind_applied", "data": { entries: Array, input: Array, summary: RewindSummary, } } | { "event": "workers_listed", "data": { workers: unknown, } } | { "event": "worker_restored", "data": { result: unknown, } } | { "event": "peer_registered", "data": { result: unknown, } } | { "event": "alert", "data": Alert } | { "event": "memory_worker", "data": MemoryWorkerEvent } | { "event": "compact_start" } | { "event": "compact_done", "data": { new_segment_id: string, } } | { "event": "compact_failed", "data": { error: string, } } | { "event": "shutdown" }; diff --git a/web/workspace/src/lib/workspace/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 041fdbfb..dbd701b9 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -1287,7 +1287,10 @@ Deno.test("Internal Worker output stays separate and revision-fenced", () => { }]); assertEquals(projection.lines, []); assertEquals(projection.internalWorkers.length, 1); - assertEquals(projection.internalWorkers[0].console.lines[0].body, "child output"); + assertEquals( + projection.internalWorkers[0].console.lines[0].body, + "child output", + ); projection = projector.append([{ eventId: "2", @@ -1364,15 +1367,112 @@ Deno.test("parent snapshot authoritatively replaces Internal Worker projections" }, }]); const projection = projector.append([{ eventId: "snapshot", event }]); - assertEquals(projection.internalWorkers.map((worker) => worker.worker.session_id), [ - "replacement", - ]); + assertEquals( + projection.internalWorkers.map((worker) => worker.worker.session_id), + [ + "replacement", + ], + ); const childLines = projection.internalWorkers[0].console.lines; assertEquals(childLines.length, 1); assertEquals(new Set(childLines.map((line) => line.id)).size, 1); assertEquals(childLines[0].kind, "tool"); }); +Deno.test("terminal Internal Worker removal drops descendants and fences late events", () => { + const worker = { + session_id: "child-session", + name: "child", + parent_session_id: "parent-session", + kind: "sub_worker" as const, + }; + const nestedWorker = { + session_id: "grandchild-session", + name: "grandchild", + parent_session_id: "child-session", + kind: "sub_worker" as const, + }; + const projector = createConsoleProjector(); + let projection = projector.append([{ + eventId: "child", + event: { + event: "internal_worker", + data: { + worker, + revision: 2, + event: { + event: "internal_worker", + data: { + worker: nestedWorker, + revision: 1, + event: { event: "text_done", data: { text: "nested" } }, + }, + }, + }, + }, + }]); + assertEquals(projection.internalWorkers.length, 1); + assertEquals( + projection.internalWorkers[0].console.internalWorkers.length, + 1, + ); + + projection = projector.append([{ + eventId: "removed", + event: { + event: "internal_worker_removed", + data: { worker, revision: 3 }, + }, + }, { + eventId: "late", + event: { + event: "internal_worker", + data: { + worker, + revision: 4, + event: { event: "text_done", data: { text: "must stay removed" } }, + }, + }, + }]); + assertEquals(projection.internalWorkers, []); + + const snapshot = snapshotEvent("/repo"); + projection = projector.append([{ eventId: "snapshot", event: snapshot }]); + assertEquals(projection.internalWorkers, []); + assertEquals(projection.removedInternalWorkers, {}); +}); + +Deno.test("stale Internal Worker removal cannot discard a newer projection", () => { + const worker = { + session_id: "child-session", + name: "child", + parent_session_id: "parent-session", + kind: "sub_worker" as const, + }; + const projector = createConsoleProjector(); + projector.append([{ + eventId: "current", + event: { + event: "internal_worker", + data: { + worker, + revision: 4, + event: { event: "text_done", data: { text: "current" } }, + }, + }, + }]); + + const projection = projector.append([{ + eventId: "stale-removal", + event: { + event: "internal_worker_removed", + data: { worker, revision: 3 }, + }, + }]); + assertEquals(projection.internalWorkers.length, 1); + assertEquals(projection.internalWorkers[0].revision, 4); +}); + Deno.test("snapshot restores TaskStore state from system history", () => { const taskSnapshot = `[Session TaskStore snapshot]\n\n\`\`\`json\n{\n "tasks": [{"taskid": 3, "status": "pending", "subject": "Restored", "description": "From compaction"}]\n}\n\`\`\``; diff --git a/web/workspace/src/lib/workspace/console/model.ts b/web/workspace/src/lib/workspace/console/model.ts index 2e632fff..cd7d6e44 100644 --- a/web/workspace/src/lib/workspace/console/model.ts +++ b/web/workspace/src/lib/workspace/console/model.ts @@ -94,6 +94,8 @@ export type ConsoleProjection = { cwd: string | null; lastEventId: string | null; internalWorkers: InternalWorkerProjection[]; + /** Terminal child-session fences, reset only by an authoritative snapshot. */ + removedInternalWorkers: Record; }; export type ConsoleTimelineLineSelection = { @@ -179,6 +181,7 @@ export function emptyConsoleProjection(): ConsoleProjection { cwd: null, lastEventId: null, internalWorkers: [], + removedInternalWorkers: {}, }; } @@ -286,6 +289,7 @@ export function applyProtocolEvent( cwd: projection.cwd, lastEventId: envelope.eventId, internalWorkers: [...projection.internalWorkers], + removedInternalWorkers: { ...projection.removedInternalWorkers }, }; const event = envelope.event; @@ -406,9 +410,16 @@ export function applyProtocolEvent( next.internalWorkers = (event.data.internal_workers ?? []).map((worker) => projectInternalWorkerSnapshot(worker, envelope.eventId, next.cwd) ); + next.removedInternalWorkers = {}; break; } case "internal_worker": { + if ( + Object.hasOwn( + next.removedInternalWorkers, + event.data.worker.session_id, + ) + ) break; const existingIndex = next.internalWorkers.findIndex((worker) => worker.worker.session_id === event.data.worker.session_id ); @@ -433,6 +444,19 @@ export function applyProtocolEvent( else next.internalWorkers.push(updated); break; } + case "internal_worker_removed": { + const existingIndex = next.internalWorkers.findIndex((worker) => + worker.worker.session_id === event.data.worker.session_id + ); + const existingRevision = existingIndex >= 0 + ? next.internalWorkers[existingIndex].revision + : 0; + if (event.data.revision <= existingRevision) break; + next.removedInternalWorkers[event.data.worker.session_id] = + event.data.revision; + if (existingIndex >= 0) next.internalWorkers.splice(existingIndex, 1); + break; + } case "status": next.status = event.data.status; break; @@ -1294,6 +1318,7 @@ function snapshotProjectionFromEntries( cwd, lastEventId: eventId, internalWorkers: [], + removedInternalWorkers: {}, }; entries.forEach((entry, index) => applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry) From d7e54ed1814e62b1f14b5a194ecf721e696d396e Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 11:21:31 +0900 Subject: [PATCH 09/22] fix: preserve retained assignment history --- crates/workspace-server/src/store.rs | 80 ++++++++++++++++++- .../workspace-schema-migrations.md | 2 +- 2 files changed, 77 insertions(+), 5 deletions(-) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index a66e4e80..00243bd9 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -5236,13 +5236,18 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ FROM ticket_worker_assignments AS assignment \ WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ WHERE ticket.workspace_id = assignment.workspace_id \ - AND ticket.ticket_id = assignment.ticket_id) LIMIT 100", + AND ticket.ticket_id = assignment.ticket_id) \ + AND EXISTS (SELECT 1 FROM typed_tickets AS foreign_ticket \ + WHERE foreign_ticket.ticket_id = assignment.ticket_id) LIMIT 100", ), ( "ticket_worker_assignments.worker_id", @@ -5250,8 +5255,9 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result Result Date: Thu, 20 Aug 2026 11:28:41 +0900 Subject: [PATCH 10/22] fix: validate assignment runtime snapshots --- crates/workspace-server/src/store.rs | 25 ++++++++++++++++--- .../workspace-schema-migrations.md | 2 +- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 00243bd9..db1e06cd 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -5237,8 +5237,9 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ @@ -5255,9 +5256,10 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result Date: Thu, 20 Aug 2026 11:38:13 +0900 Subject: [PATCH 11/22] fix: track retained assignment parents --- crates/workspace-server/src/store.rs | 220 ++++++++++++++---- .../workspace-schema-migrations.md | 2 +- 2 files changed, 181 insertions(+), 41 deletions(-) diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index db1e06cd..a00fb91d 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -5134,6 +5134,9 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { return Ok(()); } for trigger in [ + "ticket_assignment_ticket_parent_tombstone", + "ticket_assignment_worker_parent_tombstone_delete", + "ticket_assignment_worker_parent_tombstone_move", "ticket_worker_assignments_validate_insert", "ticket_worker_assignments_validate_update", "ticket_worker_assignment_events_validate_insert", @@ -5236,31 +5239,6 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ - FROM ticket_worker_assignments AS assignment \ - WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ - WHERE ticket.workspace_id = assignment.workspace_id \ - AND ticket.ticket_id = assignment.ticket_id) \ - AND EXISTS (SELECT 1 FROM typed_tickets AS foreign_ticket \ - WHERE foreign_ticket.ticket_id = assignment.ticket_id) LIMIT 100", - ), - ( - "ticket_worker_assignments.worker_id", - "SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || assignment.runtime_id || '/' || assignment.worker_id \ - FROM ticket_worker_assignments AS assignment \ - WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \ - WHERE worker.workspace_id = assignment.workspace_id \ - AND worker.runtime_id = assignment.runtime_id \ - AND worker.worker_id = assignment.worker_id) \ - AND EXISTS (SELECT 1 FROM worker_registry AS live_worker \ - WHERE live_worker.worker_id = assignment.worker_id) LIMIT 100", - ), ( "ticket_current_worker_assignments.assignment_id", "SELECT current.workspace_id || '/' || current.assignment_id \ @@ -5287,16 +5265,6 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || operation.ticket_id \ - FROM ticket_assignment_operations AS operation \ - WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ - WHERE ticket.workspace_id = operation.workspace_id \ - AND ticket.ticket_id = operation.ticket_id) \ - AND EXISTS (SELECT 1 FROM typed_tickets AS foreign_ticket \ - WHERE foreign_ticket.ticket_id = operation.ticket_id) LIMIT 100", - ), ( "artifacts.ticket_id", "SELECT artifact.workspace_id || '/' || artifact.artifact_id || ' -> ' || artifact.ticket_id \ @@ -5345,6 +5313,82 @@ fn workspace_resource_reference_diagnostics(conn: &Connection) -> Result ' || assignment.ticket_id \ + FROM ticket_worker_assignments AS assignment \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = assignment.workspace_id \ + AND ticket.ticket_id = assignment.ticket_id) \ + {tombstone_filter} LIMIT 100" + ), + "ticket_worker_assignments.ticket_id", + &mut diagnostics, + )?; + } + if table_exists(conn, "ticket_worker_assignments")? + && table_exists(conn, "worker_registry")? + && column_exists(conn, "ticket_worker_assignments", "worker_id")? + && column_exists(conn, "worker_registry", "worker_id")? + { + let tombstone_filter = if table_exists(conn, "ticket_assignment_worker_tombstones")? { + "AND NOT EXISTS (SELECT 1 FROM ticket_assignment_worker_tombstones AS tombstone \ + WHERE tombstone.workspace_id = assignment.workspace_id \ + AND tombstone.runtime_id = assignment.runtime_id \ + AND tombstone.worker_id = assignment.worker_id)" + } else { + "" + }; + collect_reference_diagnostics( + conn, + &format!( + "SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || assignment.runtime_id || '/' || assignment.worker_id \ + FROM ticket_worker_assignments AS assignment \ + WHERE NOT EXISTS (SELECT 1 FROM worker_registry AS worker \ + WHERE worker.workspace_id = assignment.workspace_id \ + AND worker.runtime_id = assignment.runtime_id \ + AND worker.worker_id = assignment.worker_id) \ + {tombstone_filter} LIMIT 100" + ), + "ticket_worker_assignments.worker_id", + &mut diagnostics, + )?; + } + if table_exists(conn, "ticket_assignment_operations")? && table_exists(conn, "typed_tickets")? { + let tombstone_filter = if table_exists(conn, "ticket_assignment_ticket_tombstones")? { + "AND NOT EXISTS (SELECT 1 FROM ticket_assignment_ticket_tombstones AS tombstone \ + WHERE tombstone.workspace_id = operation.workspace_id \ + AND tombstone.ticket_id = operation.ticket_id)" + } else { + "" + }; + collect_reference_diagnostics( + conn, + &format!( + "SELECT operation.workspace_id || '/' || operation.operation_id || ' -> ' || operation.ticket_id \ + FROM ticket_assignment_operations AS operation \ + WHERE NOT EXISTS (SELECT 1 FROM typed_tickets AS ticket \ + WHERE ticket.workspace_id = operation.workspace_id \ + AND ticket.ticket_id = operation.ticket_id) \ + {tombstone_filter} LIMIT 100" + ), + "ticket_assignment_operations.ticket_id", + &mut diagnostics, + )?; + } Ok(diagnostics) } @@ -6243,6 +6287,23 @@ CREATE TABLE objective_ticket_links_v39 ( ); INSERT INTO objective_ticket_links_v39 SELECT * FROM objective_ticket_links; +CREATE TABLE ticket_assignment_ticket_tombstones ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); + +CREATE TABLE ticket_assignment_worker_tombstones ( + workspace_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, + worker_id TEXT NOT NULL, + deleted_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); + CREATE TABLE ticket_worker_assignments_v39 ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, @@ -6438,13 +6499,61 @@ CREATE INDEX IF NOT EXISTS idx_workspace_resource_human_keys_reverse })?; } // Assignment rows and events are historical evidence and intentionally survive Ticket or - // Worker retention deletion, so parent FKs would impose the wrong delete semantics. These - // triggers provide the equivalent database-layer insertion boundary: every new assignment - // resolves both authorities in the same Workspace, and event references resolve a committed - // assignment for the same Ticket. Operation assignment/Worker ids remain unconstrained because reservations are - // persisted before assignment/Worker creation and expected ids may intentionally be stale. + // Worker retention deletion, so parent FKs would impose the wrong delete semantics. Parent + // delete/move triggers record an exact tombstone before authority disappears; insertion + // triggers require every new assignment to resolve both authorities in the same Workspace, + // and event references resolve a committed assignment for the same Ticket. Operation + // assignment/Worker ids remain unconstrained because reservations are persisted before + // assignment/Worker creation and expected ids may intentionally be stale. conn.execute_batch( r#" +CREATE TRIGGER ticket_assignment_ticket_parent_tombstone +BEFORE DELETE ON typed_tickets +WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.ticket_id = OLD.ticket_id + ) + OR EXISTS ( + SELECT 1 FROM ticket_assignment_operations AS operation + WHERE operation.workspace_id = OLD.workspace_id + AND operation.ticket_id = OLD.ticket_id + ) +BEGIN + INSERT OR IGNORE INTO ticket_assignment_ticket_tombstones ( + workspace_id, ticket_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.ticket_id, CURRENT_TIMESTAMP); +END; + +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_delete +BEFORE DELETE ON worker_registry +WHEN EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id +) +BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); +END; + +CREATE TRIGGER ticket_assignment_worker_parent_tombstone_move +BEFORE UPDATE OF runtime_id ON worker_registry +WHEN OLD.runtime_id != NEW.runtime_id + AND EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = OLD.workspace_id + AND assignment.runtime_id = OLD.runtime_id + AND assignment.worker_id = OLD.worker_id +) +BEGIN + INSERT OR IGNORE INTO ticket_assignment_worker_tombstones ( + workspace_id, runtime_id, worker_id, deleted_at + ) VALUES (OLD.workspace_id, OLD.runtime_id, OLD.worker_id, CURRENT_TIMESTAMP); +END; + CREATE TRIGGER ticket_worker_assignments_validate_insert BEFORE INSERT ON ticket_worker_assignments WHEN NOT EXISTS ( @@ -8340,6 +8449,18 @@ DELETE FROM worker_registry WHERE workspace_id = 'workspace-a' AND worker_id = '00000000-0000-7000-8000-000000000001'; DELETE FROM typed_tickets WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'; +INSERT INTO workspaces (workspace_id, display_name, state, created_at, updated_at) +VALUES ('workspace-b', 'B', 'active', '2026-01-01', '2026-01-01'); +INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit +) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', '', 'planning', 1); +INSERT INTO worker_registry ( + workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at +) VALUES ( + 'workspace-b', 'runtime-b', '00000000-0000-7000-8000-000000000001', + 'Worker B', 'normal', '2026-01-01', '2026-01-01' +); "#, )?; Ok(()) @@ -8356,6 +8477,18 @@ WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'; |row| row.get(0), )?; assert_eq!(retained, 1); + let ticket_tombstones: i64 = conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_ticket_tombstones WHERE workspace_id = 'workspace-a' AND ticket_id = 'ticket-a'", + [], + |row| row.get(0), + )?; + let worker_tombstones: i64 = conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_worker_tombstones WHERE workspace_id = 'workspace-a' AND runtime_id = 'runtime-a' AND worker_id = '00000000-0000-7000-8000-000000000001'", + [], + |row| row.get(0), + )?; + assert_eq!(ticket_tombstones, 1); + assert_eq!(worker_tombstones, 1); Ok(()) }) .unwrap(); @@ -8509,6 +8642,12 @@ INSERT INTO ticket_worker_assignments ( 'workspace-b', 'ticket-b', 'assignment-runtime-mismatch', 'runtime-wrong', '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01' ); +INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at +) VALUES ( + 'workspace-b', 'ticket-missing', 'assignment-missing-parents', 'runtime-missing', + '00000000-0000-7000-8000-000000000003', 'tester', '2026-01-01' +); INSERT INTO ticket_worker_assignments ( workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at ) VALUES ( @@ -8552,6 +8691,7 @@ INSERT INTO ticket_worker_assignment_events ( ); assert!(error.contains("assignment-cross-worker"), "{error}"); assert!(error.contains("assignment-runtime-mismatch"), "{error}"); + assert!(error.contains("assignment-missing-parents"), "{error}"); assert!( error.contains("ticket_worker_assignment_events.assignment_id"), "{error}" diff --git a/docs/development/workspace-schema-migrations.md b/docs/development/workspace-schema-migrations.md index 27eabfc1..aac1c97e 100644 --- a/docs/development/workspace-schema-migrations.md +++ b/docs/development/workspace-schema-migrations.md @@ -21,7 +21,7 @@ Start exactly one instance of the new Server binary against the database. Startu - rebuilds Ticket, Objective, assignment, Artifact, and human-key tables with Workspace-scoped composite identity; - adds composite foreign keys for repository, Ticket, Objective, Worker, relation-target, and current-assignment references; -- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; startup treats a Ticket or Worker ID missing from every Workspace as retained history, but rejects a Ticket ID in another Workspace and rejects a live Worker ID whose Workspace/Runtime placement does not match the assignment snapshot; reservation operation ids remain intentionally unconstrained until their resources exist; +- validates new historical assignment/event references with SQLite triggers while allowing those audit rows to survive later Ticket or Worker retention deletion; parent delete/Runtime-move triggers record exact Workspace-scoped tombstones, and startup accepts a missing live parent only when that tombstone exists, so an unrelated same ID in another Workspace cannot change the result; reservation operation ids remain intentionally unconstrained until their resources exist; - checks the rebuilt schema with `PRAGMA foreign_key_check` before recording the schema version; and - restores `PRAGMA foreign_keys = ON` whether the transaction commits or rolls back. From cf2e74404dc1e468595075694843bd922f1484f2 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 17:28:14 +0900 Subject: [PATCH 12/22] feat: dispatch registered workspaces independently --- crates/workspace-server/Cargo.toml | 1 + crates/workspace-server/src/hosts.rs | 8 + crates/workspace-server/src/lib.rs | 10 +- crates/workspace-server/src/main.rs | 135 +++--- crates/workspace-server/src/server.rs | 432 ++++++++++++++++++ crates/workspace-server/src/store.rs | 293 ++++++++++-- .../workspace-server/src/workspace_catalog.rs | 321 +++++++++++++ 7 files changed, 1119 insertions(+), 81 deletions(-) create mode 100644 crates/workspace-server/src/workspace_catalog.rs diff --git a/crates/workspace-server/Cargo.toml b/crates/workspace-server/Cargo.toml index b2008cfb..73e775fb 100644 --- a/crates/workspace-server/Cargo.toml +++ b/crates/workspace-server/Cargo.toml @@ -35,6 +35,7 @@ ticket.workspace = true memory.workspace = true merge-request.workspace = true tokio = { workspace = true, features = ["fs", "macros", "net", "rt-multi-thread", "sync", "time"] } +tower.workspace = true tokio-tungstenite.workspace = true worker.workspace = true workdir = { workspace = true, features = ["http-client"] } diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index f02ac49f..1add0d14 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -2447,6 +2447,8 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { #[derive(Clone)] pub struct RemoteRuntimeConfig { pub runtime_id: String, + /// Explicit Workspace assignment granted by Server authority. + pub workspace_id: Option, pub display_name: String, pub base_url: String, pub bearer_token: Option, @@ -2489,6 +2491,7 @@ impl RemoteRuntimeConfig { ) -> Self { Self { runtime_id: runtime_id.into(), + workspace_id: None, display_name: display_name.into(), base_url: base_url.into(), bearer_token, @@ -2501,6 +2504,11 @@ impl RemoteRuntimeConfig { } } + pub fn with_workspace_id(mut self, workspace_id: impl Into) -> Self { + self.workspace_id = Some(workspace_id.into()); + self + } + pub fn with_cached_capabilities(mut self, capabilities: RuntimeCapabilitySummary) -> Self { self.cached_capabilities = capabilities; self diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index d504bb11..d8ca871d 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -27,6 +27,7 @@ pub mod server; pub mod skills; pub mod store; pub mod worker_source; +pub mod workspace_catalog; mod workspace_subscription; pub use authority::{ @@ -45,8 +46,15 @@ pub use repositories::{ ConfiguredRepository, GitCommitSummary, GitRemoteSummary, GitRepositorySummary, RepositoryLogRead, RepositoryRegistryReader, RepositorySummary, }; -pub use server::{AuthConfig, ServerConfig, WorkspaceApi, build_router, serve}; +pub use server::{ + AuthConfig, ServerConfig, WorkspaceApi, WorkspaceServerApi, build_router, + build_workspace_server_router, serve, serve_workspace_catalog, +}; pub use store::{ControlPlaneStore, SqliteWorkspaceStore, WorkspaceRecord}; +pub use workspace_catalog::{ + InitialRepositoryIntent, WorkspaceCatalogService, WorkspaceCreateRequest, + WorkspaceCreateResponse, +}; use worker_runtime::identity::RuntimeWorkerRef; diff --git a/crates/workspace-server/src/main.rs b/crates/workspace-server/src/main.rs index 77a59e4f..0ccee280 100644 --- a/crates/workspace-server/src/main.rs +++ b/crates/workspace-server/src/main.rs @@ -9,10 +9,11 @@ use serde::{Deserialize, Serialize}; use tokio::net::TcpListener; use worker_runtime::auth::{RuntimeIdentityMaterial, decode_public_key}; use yoi_workspace_server::hosts::{RemoteRuntimeAuthConfig, RemoteRuntimeConfig}; -use yoi_workspace_server::store::{RepositoryRecord, SqliteWorkspaceStore, TrustedRuntimeRecord}; +use yoi_workspace_server::store::{SqliteWorkspaceStore, TrustedRuntimeRecord}; use yoi_workspace_server::{ - BackendRuntimesConfigFile, ControlPlaneStore, ServerConfig, WORKSPACE_BACKEND_CONFIG_TEMPLATE, - WorkspaceBackendConfigFile, WorkspaceIdentity, WorkspaceRecord, serve, + BackendRuntimesConfigFile, ControlPlaneStore, InitialRepositoryIntent, ServerConfig, + WORKSPACE_BACKEND_CONFIG_TEMPLATE, WorkspaceBackendConfigFile, WorkspaceCatalogService, + WorkspaceCreateRequest, WorkspaceIdentity, WorkspaceRecord, serve_workspace_catalog, }; #[derive(Debug)] @@ -152,30 +153,21 @@ async fn run_init_with_database_path( if let Some(parent) = database_path.parent() { tokio::fs::create_dir_all(parent).await?; } - let store = SqliteWorkspaceStore::open(&database_path)?; - store - .upsert_workspace(&WorkspaceRecord { - workspace_id: identity.workspace_id.clone(), - owner_account_id: None, + let store = Arc::new(SqliteWorkspaceStore::open(&database_path)?); + let service = WorkspaceCatalogService::new(store); + service.create_with_workspace_id( + WorkspaceCreateRequest { + operation_key: format!("cli-init:{}", identity.workspace_id), display_name: identity.display_name.clone(), - state: "active".to_string(), - created_at: identity.created_at.clone(), - updated_at: identity.created_at.clone(), - }) - .await?; - store.upsert_repository(&RepositoryRecord { - workspace_id: identity.workspace_id.clone(), - repository_id: "main".to_string(), - name: "Main repository".to_string(), - kind: "git".to_string(), - provider: Some("git".to_string()), - uri: options.workspace.display().to_string(), - default_ref: Some("HEAD".to_string()), - auth_ref_kind: None, - auth_ref_key: None, - created_at: identity.created_at.clone(), - updated_at: identity.created_at.clone(), - })?; + repository: InitialRepositoryIntent { + uri: options.workspace.display().to_string(), + display_name: Some("Main repository".to_string()), + default_ref: Some("HEAD".to_string()), + }, + }, + None, + Some(identity.workspace_id.clone()), + )?; eprintln!( "yoi-server: initialized workspace `{}` ({}) in server DB `{}`", @@ -358,6 +350,7 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box { let mut runtime_id = None; + let mut workspace_id = None; let mut base_url = None; let mut public_key = None; let mut display_name = None; @@ -368,6 +361,9 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box { runtime_id = Some(take_value(&flag, inline_value, &mut args)?) } + "--workspace-id" => { + workspace_id = Some(take_value(&flag, inline_value, &mut args)?) + } "--base-url" | "--endpoint" => { base_url = Some(take_value(&flag, inline_value, &mut args)?) } @@ -390,15 +386,39 @@ fn run_trust_runtime_command(args: Vec) -> Result<(), Box) -> Result<(), Box Result<(), Box Result<(), Box Result<(), Box Result { - let workspaces = store - .list_workspaces() - .map_err(|error| CliError(format!("failed to list workspaces from server DB: {error}")))?; - match workspaces.as_slice() { - [] => Err(CliError( - "server DB has no workspace records; run `yoi-server init --workspace `" - .to_string(), - )), - [workspace] => Ok(workspace.clone()), - _ => Err(CliError(format!( - "server DB contains {} workspaces; serve workspace selection is not implemented yet", - workspaces.len() - ))), - } -} - fn infer_workspace_root_from_repositories( store: &SqliteWorkspaceStore, workspace: &WorkspaceRecord, @@ -914,7 +939,7 @@ fn parse_listen(value: &str) -> Result { fn print_help() { println!( - "yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config [OPTIONS]\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server migrate --dry-run [--database ] + "yoi-server\n\nUsage:\n yoi-server init [OPTIONS]\n yoi-server config [OPTIONS]\n yoi-server identity init --server-id [--replace]\n yoi-server identity show [--json]\n yoi-server trust-runtime add --runtime-id --workspace-id --base-url --public-key [--display-name ] [--replace]\n yoi-server trust-runtime list [--json] [--include-revoked]\n yoi-server trust-runtime revoke --runtime-id \n yoi-server skills [OPTIONS]\n yoi-server migrate --dry-run [--database ] yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help" ); } @@ -1038,6 +1063,7 @@ mod tests { store .upsert_trusted_runtime(&TrustedRuntimeRecord { runtime_id: "runtime-a".to_string(), + workspace_id: None, display_name: "Runtime A".to_string(), base_url: "http://127.0.0.1:18080".to_string(), public_key, @@ -1059,6 +1085,7 @@ mod tests { async fn init_creates_identity_local_config_and_server_records() { let temp = tempfile::tempdir().unwrap(); let database_path = temp.path().join("data").join("server").join("server.db"); + std::fs::create_dir(temp.path().join(".git")).unwrap(); run_init_with_database_path( InitOptions { workspace: temp.path().canonicalize().unwrap(), diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 578ad3d0..3642531f 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -32,9 +32,11 @@ use ticket::{ execute_ticket_backend_operation, }; use tokio::net::TcpListener; +use tokio::sync::Mutex as AsyncMutex; use tokio_tungstenite::connect_async; use tokio_tungstenite::tungstenite::Message as TungsteniteMessage; use tokio_tungstenite::tungstenite::client::IntoClientRequest; +use tower::ServiceExt; use url::Url; use uuid::Uuid; use webauthn_rs::prelude::{ @@ -111,6 +113,7 @@ use crate::store::{ TicketWorkerAssignmentRecord, UserRecord, WorkdirRegistryRecord, WorkerControlGrantRecord, WorkerRegistryRecord, WorkerWorkdirLinkRecord, WorkspaceRecord, WorkspaceResourceKind, }; +use crate::workspace_catalog::{WorkspaceCatalogService, WorkspaceCreateRequest}; use crate::{Error, Result}; use worker_runtime::catalog::{ ConfigBundleRef, ProfileSelector, RepositorySelector as RuntimeRepositorySelector, @@ -158,6 +161,9 @@ pub struct ServerConfig { pub remote_runtime_sources: Vec, pub runtime_config_path: Option, pub backend_base_url: Option, + /// Allows the first ownerless Workspace to be created without a session. + /// This must only be enabled for a loopback-bound local Server. + pub allow_local_workspace_bootstrap: bool, } impl ServerConfig { @@ -187,6 +193,7 @@ impl ServerConfig { remote_runtime_sources: Vec::new(), runtime_config_path: BackendRuntimesConfigFile::default_path(), backend_base_url: None, + allow_local_workspace_bootstrap: false, } } @@ -243,10 +250,69 @@ impl ServerConfig { Self::default_workspace_backend_data_root(workspace_id).join("embedded-runtime") } + pub fn with_local_workspace_bootstrap(mut self, enabled: bool) -> Self { + self.allow_local_workspace_bootstrap = enabled; + self + } + pub fn with_embedded_runtime_store_root(mut self, root: impl Into) -> Self { self.embedded_runtime_store_root = root.into(); self } + + fn for_catalog_workspace( + &self, + workspace: &WorkspaceRecord, + repositories: Vec, + ) -> Result { + let primary = repositories + .iter() + .find(|repository| repository.repository_id == "main") + .or_else(|| repositories.first()) + .ok_or_else(|| { + Error::Config(format!( + "Workspace {} has no registered repository", + workspace.workspace_id + )) + })?; + let workspace_root = PathBuf::from(&primary.uri); + if !workspace_root.is_absolute() { + return Err(Error::Config(format!( + "Workspace {} repository uri is not an absolute local path", + workspace.workspace_id + ))); + } + let repositories = repositories + .into_iter() + .map(|repository| ConfiguredRepository { + id: repository.repository_id, + provider: repository.provider.unwrap_or(repository.kind), + path: PathBuf::from(&repository.uri), + uri: repository.uri, + display_name: Some(repository.name), + default_selector: repository.default_ref, + }) + .collect(); + let mut scoped = self.clone(); + scoped.workspace_id.clone_from(&workspace.workspace_id); + scoped + .workspace_display_name + .clone_from(&workspace.display_name); + scoped + .workspace_created_at + .clone_from(&workspace.created_at); + scoped.workspace_root = workspace_root; + scoped.embedded_runtime_store_root = + Self::default_embedded_runtime_store_root(&workspace.workspace_id); + scoped.repositories = repositories; + // Runtime trust is server-global. Only explicitly assigned sources enter + // this Workspace's registry and receive Workspace-scoped capabilities. + scoped.remote_runtime_sources.retain(|runtime| { + runtime.workspace_id.as_deref() == Some(workspace.workspace_id.as_str()) + }); + scoped.runtime_event_sources.clear(); + Ok(scoped) + } } const ORCHESTRATOR_ATTENTION_TICKET_LIMIT: usize = 20; @@ -682,6 +748,216 @@ impl crate::worker_source::VerifiedWorkerRemoveExecutor for WorkspaceWorkerRemov } } +#[derive(Clone)] +pub struct WorkspaceServerApi { + template: Arc, + store: Arc, + catalog: WorkspaceCatalogService, + routers: Arc>>, +} + +impl WorkspaceServerApi { + pub fn new(template: ServerConfig, store: Arc) -> Self { + Self { + template: Arc::new(template), + catalog: WorkspaceCatalogService::new(store.clone()), + store, + routers: Arc::new(AsyncMutex::new(HashMap::new())), + } + } + + async fn router_for_workspace(&self, workspace_id: &str) -> Result> { + let mut routers = self.routers.lock().await; + if let Some(router) = routers.get(workspace_id) { + return Ok(Some(router.clone())); + } + let Some(workspace) = self.store.get_workspace(workspace_id).await? else { + return Ok(None); + }; + let repositories = self.store.list_repositories(workspace_id)?; + let config = self + .template + .for_catalog_workspace(&workspace, repositories)?; + let api = WorkspaceApi::new(config, self.store.clone()).await?; + tokio::spawn(run_orchestrator_turn_end_hook(api.clone())); + let router = build_router(api); + routers.insert(workspace_id.to_string(), router.clone()); + Ok(Some(router)) + } + + async fn preload(&self) -> Result<()> { + for workspace in self.store.list_workspaces()? { + let _ = self + .router_for_workspace(&workspace.workspace_id) + .await? + .ok_or_else(|| { + Error::Config(format!( + "Workspace {} disappeared while loading", + workspace.workspace_id + )) + })?; + } + Ok(()) + } +} + +fn server_error_response(error: Error) -> Response { + ApiError::from(error).into_response() +} + +fn forbidden_server_response(message: &str) -> Response { + ( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ "error": message })), + ) + .into_response() +} + +#[derive(Debug, Deserialize)] +struct WorkspaceListQuery { + limit: Option, +} + +async fn list_server_workspaces( + State(api): State, + headers: HeaderMap, + Query(query): Query, +) -> Response { + let owner = match resolve_server_actor(&api, &headers).await { + Ok(Some(actor)) => Some(actor.account_id), + Ok(None) => None, + Err(error) => return server_error_response(error), + }; + match api + .catalog + .list(owner.as_deref(), query.limit.unwrap_or(100)) + { + Ok(workspaces) => Json(workspaces).into_response(), + Err(error) => server_error_response(error), + } +} + +async fn create_server_workspace( + State(api): State, + headers: HeaderMap, + Json(request): Json, +) -> Response { + let owner_account_id = match resolve_server_actor(&api, &headers).await { + Ok(Some(actor)) => Some(actor.account_id), + Ok(None) if api.template.allow_local_workspace_bootstrap => { + match api.store.list_workspaces() { + Ok(workspaces) if workspaces.is_empty() => None, + Ok(_) => { + return forbidden_server_response( + "Workspace creation requires an authenticated owner", + ); + } + Err(error) => return server_error_response(error), + } + } + Ok(None) => { + return forbidden_server_response("Workspace creation requires an authenticated owner"); + } + Err(error) => return server_error_response(error), + }; + let created = match api.catalog.create(request, owner_account_id) { + Ok(created) => created, + Err(error) => return server_error_response(error), + }; + if let Err(error) = api + .router_for_workspace(&created.workspace.workspace_id) + .await + { + return server_error_response(error); + } + let status = if created.replayed { + StatusCode::OK + } else { + StatusCode::CREATED + }; + (status, Json(created)).into_response() +} + +async fn resolve_server_actor( + api: &WorkspaceServerApi, + headers: &HeaderMap, +) -> std::result::Result, Error> { + let cookie_name = auth_public_config(api.template.as_ref()).cookie_name; + resolve_request_actor(api.store.as_ref(), headers, &cookie_name).await +} + +async fn dispatch_workspace_request( + State(api): State, + request: Request, +) -> Response { + let path = request.uri().path(); + let workspace_id = scoped_workspace_id(path); + let router = if let Some(workspace_id) = workspace_id { + match api.router_for_workspace(workspace_id).await { + Ok(Some(router)) => Some(router), + Ok(None) => None, + Err(error) => return server_error_response(error), + } + } else { + let workspaces = match api.store.list_workspaces() { + Ok(workspaces) => workspaces, + Err(error) => return server_error_response(error), + }; + if workspaces.len() == 1 || is_server_global_forward(path) { + match workspaces.first() { + Some(workspace) => match api.router_for_workspace(&workspace.workspace_id).await { + Ok(router) => router, + Err(error) => return server_error_response(error), + }, + None => None, + } + } else { + None + } + }; + let Some(router) = router else { + return StatusCode::NOT_FOUND.into_response(); + }; + match router.oneshot(request).await { + Ok(response) => response, + Err(error) => match error {}, + } +} + +fn is_server_global_forward(path: &str) -> bool { + path == "/api/auth" + || path.starts_with("/api/auth/") + || path == "/health" + || path == "/" + || path.starts_with("/assets/") +} + +fn scoped_workspace_id(path: &str) -> Option<&str> { + let mut segments = path.trim_start_matches('/').split('/'); + match (segments.next(), segments.next(), segments.next()) { + (Some("api"), Some("w"), Some(workspace_id)) if !workspace_id.is_empty() => { + Some(workspace_id) + } + (Some("w"), Some(workspace_id), _) if !workspace_id.is_empty() => Some(workspace_id), + _ => None, + } +} + +pub async fn build_workspace_server_router( + template: ServerConfig, + store: Arc, +) -> Result { + let api = WorkspaceServerApi::new(template, store); + api.preload().await?; + Ok(Router::new() + .route( + "/api/workspaces", + get(list_server_workspaces).post(create_server_workspace), + ) + .fallback(dispatch_workspace_request) + .with_state(api)) +} + impl WorkspaceApi { pub fn with_config_schema_provider( mut self, @@ -1872,6 +2148,16 @@ struct ApiFailureLogEvent<'a> { diagnostics: Option<&'a [RuntimeDiagnostic]>, } +pub async fn serve_workspace_catalog( + template: ServerConfig, + store: Arc, + listener: TcpListener, +) -> Result<()> { + let router = build_workspace_server_router(template, store).await?; + axum::serve(listener, router).await?; + Ok(()) +} + pub async fn serve( config: ServerConfig, store: Arc, @@ -14130,6 +14416,139 @@ mod tests { config } + #[tokio::test] + async fn server_router_dispatches_two_workspace_contexts_without_state_leakage() { + let dir = tempfile::tempdir().unwrap(); + let repository_a = dir.path().join("repository-a"); + let repository_b = dir.path().join("repository-b"); + std::fs::create_dir_all(repository_a.join(".git")).unwrap(); + std::fs::create_dir_all(repository_b.join(".git")).unwrap(); + let template = test_server_config(dir.path()); + let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); + let catalog = WorkspaceCatalogService::new(store.clone()); + let workspace_a = catalog + .create( + WorkspaceCreateRequest { + operation_key: "create-a".to_string(), + display_name: "Workspace A".to_string(), + repository: crate::workspace_catalog::InitialRepositoryIntent { + uri: repository_a.display().to_string(), + display_name: None, + default_ref: None, + }, + }, + None, + ) + .unwrap(); + let workspace_b = catalog + .create( + WorkspaceCreateRequest { + operation_key: "create-b".to_string(), + display_name: "Workspace B".to_string(), + repository: crate::workspace_catalog::InitialRepositoryIntent { + uri: repository_b.display().to_string(), + display_name: None, + default_ref: None, + }, + }, + None, + ) + .unwrap(); + let app = build_workspace_server_router(template, store) + .await + .unwrap(); + + let uri_a = format!("/api/w/{}/workspace", workspace_a.workspace.workspace_id); + let uri_b = format!("/api/w/{}/workspace", workspace_b.workspace.workspace_id); + let (a, b) = tokio::join!(get_json(app.clone(), &uri_a), get_json(app.clone(), &uri_b)); + assert_eq!(a["workspace_id"], workspace_a.workspace.workspace_id); + assert_eq!(a["display_name"], "Workspace A"); + assert_eq!(b["workspace_id"], workspace_b.workspace.workspace_id); + assert_eq!(b["display_name"], "Workspace B"); + + let missing = app + .oneshot( + Request::builder() + .uri("/api/w/00000000-0000-0000-0000-000000000001/workspace") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(missing.status(), StatusCode::NOT_FOUND); + } + + #[tokio::test] + async fn local_bootstrap_create_activates_workspace_without_server_restart() { + let dir = tempfile::tempdir().unwrap(); + let repository = dir.path().join("repository"); + std::fs::create_dir_all(repository.join(".git")).unwrap(); + let template = test_server_config(dir.path()).with_local_workspace_bootstrap(true); + let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); + let app = build_workspace_server_router(template, store) + .await + .unwrap(); + let payload = json!({ + "operation_key": "bootstrap-1", + "display_name": "Created Workspace", + "repository": { + "uri": repository, + "display_name": "Repository", + "default_ref": "HEAD" + } + }); + + let created = app + .clone() + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/workspaces") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(created.status(), StatusCode::CREATED); + let body = to_bytes(created.into_body(), usize::MAX).await.unwrap(); + let body: Value = serde_json::from_slice(&body).unwrap(); + let workspace_id = body["workspace"]["workspace_id"].as_str().unwrap(); + + let workspace = get_json(app.clone(), &format!("/api/w/{workspace_id}/workspace")).await; + assert_eq!(workspace["display_name"], "Created Workspace"); + + let replayed = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/workspaces") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + // Local bootstrap authority is consumed after the first Workspace; even + // an exact HTTP retry must authenticate rather than creating another + // ownerless Workspace accidentally. + assert_eq!(replayed.status(), StatusCode::FORBIDDEN); + } + + #[test] + fn scoped_workspace_path_requires_an_explicit_workspace_segment() { + assert_eq!( + scoped_workspace_id("/api/w/workspace-a/tickets"), + Some("workspace-a") + ); + assert_eq!( + scoped_workspace_id("/w/workspace-b/workers"), + Some("workspace-b") + ); + assert_eq!(scoped_workspace_id("/api/workspaces"), None); + assert_eq!(scoped_workspace_id("/api/workspace"), None); + } + fn memory_staging_record_json(id: &str, claim: &str) -> String { json!({ "schema_version": 1, @@ -16022,6 +16441,7 @@ mod tests { let mut config = test_server_config(temp.path()); config.remote_runtime_sources.push(RemoteRuntimeConfig { runtime_id: "runtime-remote".to_string(), + workspace_id: Some(TEST_WORKSPACE_ID.to_string()), display_name: "Remote Runtime".to_string(), base_url: "https://runtime.invalid".to_string(), bearer_token: None, @@ -16049,8 +16469,20 @@ mod tests { timeout: std::time::Duration::from_secs(1), }); let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + owner_account_id: None, + display_name: "Test Workspace".to_string(), + state: "active".to_string(), + created_at: "2026-08-11T00:00:00Z".to_string(), + updated_at: "2026-08-11T00:00:00Z".to_string(), + }) + .await + .unwrap(); let trust = crate::store::TrustedRuntimeRecord { runtime_id: "runtime-remote".to_string(), + workspace_id: Some(TEST_WORKSPACE_ID.to_string()), display_name: "Remote Runtime".to_string(), base_url: "https://runtime.invalid".to_string(), public_key: identity.public_key.clone(), diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index a00fb91d..01a8f1b0 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -220,6 +220,11 @@ const MIGRATIONS: &[Migration] = &[ name: "enforce Workspace resource foreign keys", apply: enforce_workspace_resource_foreign_keys, }, + Migration { + version: 40, + name: "create atomic Workspace catalog operations", + apply: create_workspace_catalog_operations, + }, ]; struct Migration { @@ -266,9 +271,26 @@ pub struct RepositoryRecord { pub updated_at: String, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceBootstrapRecord { + pub operation_key: String, + pub request_fingerprint: String, + pub workspace: WorkspaceRecord, + pub repository: RepositoryRecord, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceBootstrapResult { + pub workspace: WorkspaceRecord, + pub repository: RepositoryRecord, + pub config_revision: u64, + pub replayed: bool, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct TrustedRuntimeRecord { pub runtime_id: String, + pub workspace_id: Option, pub display_name: String, pub base_url: String, pub public_key: String, @@ -584,6 +606,10 @@ pub trait ControlPlaneStore: Send + Sync { ) -> Result>; async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()>; async fn get_workspace(&self, workspace_id: &str) -> Result>; + fn create_workspace_bootstrap( + &self, + record: &WorkspaceBootstrapRecord, + ) -> Result; async fn get_trusted_runtime(&self, runtime_id: &str) -> Result>; async fn consume_worker_mutation_source_jti( &self, @@ -1217,8 +1243,8 @@ impl SqliteWorkspaceStore { self.with_conn(|conn| { conn.execute( r#"INSERT INTO trusted_runtime_records ( - runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7) + runtime_id, workspace_id, display_name, base_url, public_key, created_at, updated_at, revoked_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8) ON CONFLICT(runtime_id) DO UPDATE SET display_name = excluded.display_name, base_url = excluded.base_url, @@ -1227,6 +1253,7 @@ impl SqliteWorkspaceStore { revoked_at = excluded.revoked_at"#, params![ record.runtime_id, + record.workspace_id, record.display_name, record.base_url, record.public_key, @@ -1245,10 +1272,10 @@ impl SqliteWorkspaceStore { ) -> Result> { self.with_conn(|conn| { let sql = if include_revoked { - r#"SELECT runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at + r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, created_at, updated_at, revoked_at FROM trusted_runtime_records ORDER BY runtime_id ASC"# } else { - r#"SELECT runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at + r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, created_at, updated_at, revoked_at FROM trusted_runtime_records WHERE revoked_at IS NULL ORDER BY runtime_id ASC"# }; let mut stmt = conn.prepare(sql)?; @@ -1371,10 +1398,167 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn create_workspace_bootstrap( + &self, + record: &WorkspaceBootstrapRecord, + ) -> Result { + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + if let Some((fingerprint, workspace_id)) = tx + .query_row( + "SELECT request_fingerprint, workspace_id FROM workspace_create_operations WHERE operation_key = ?1", + params![record.operation_key], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()? + { + if fingerprint != record.request_fingerprint { + return Err(Error::WorkspaceConfigConflict( + "Workspace create operation key was already used with different input" + .to_string(), + )); + } + let workspace = tx.query_row( + r#"SELECT workspace_id, owner_account_id, display_name, state, created_at, updated_at + FROM workspaces WHERE workspace_id = ?1"#, + params![workspace_id], + read_workspace_record, + )?; + let repository = tx.query_row( + r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref, + auth_ref_kind, auth_ref_key, created_at, updated_at + FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#, + params![workspace.workspace_id, record.repository.repository_id], + read_repository_record, + )?; + let config_revision = crate::config_source::load_state(&tx, &workspace.workspace_id)? + .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))? + .snapshot + .revision; + tx.commit()?; + return Ok(WorkspaceBootstrapResult { + workspace, + repository, + config_revision, + replayed: true, + }); + } + + if let Some(existing) = tx + .query_row( + r#"SELECT workspace_id, owner_account_id, display_name, state, created_at, updated_at + FROM workspaces WHERE workspace_id = ?1"#, + params![record.workspace.workspace_id], + read_workspace_record, + ) + .optional()? + { + if existing.owner_account_id != record.workspace.owner_account_id + || existing.display_name != record.workspace.display_name + || existing.state != record.workspace.state + { + return Err(Error::WorkspaceConfigConflict( + "Workspace identity already exists with different metadata".to_string(), + )); + } + let existing_repository = tx + .query_row( + r#"SELECT workspace_id, repository_id, name, kind, provider, uri, default_ref, + auth_ref_kind, auth_ref_key, created_at, updated_at + FROM repositories WHERE workspace_id = ?1 AND repository_id = ?2"#, + params![record.repository.workspace_id, record.repository.repository_id], + read_repository_record, + ) + .optional()?; + if existing_repository.as_ref() != Some(&record.repository) { + return Err(Error::WorkspaceConfigConflict( + "Workspace initial repository already exists with different metadata" + .to_string(), + )); + } + } else { + tx.execute( + r#"INSERT INTO workspaces ( + workspace_id, owner_account_id, display_name, state, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#, + params![ + record.workspace.workspace_id, + record.workspace.owner_account_id, + record.workspace.display_name, + record.workspace.state, + record.workspace.created_at, + record.workspace.updated_at, + ], + )?; + tx.execute( + r#"INSERT INTO repositories ( + workspace_id, repository_id, name, kind, provider, uri, default_ref, + auth_ref_kind, auth_ref_key, created_at, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)"#, + params![ + record.repository.workspace_id, + record.repository.repository_id, + record.repository.name, + record.repository.kind, + record.repository.provider, + record.repository.uri, + record.repository.default_ref, + record.repository.auth_ref_kind, + record.repository.auth_ref_key, + record.repository.created_at, + record.repository.updated_at, + ], + )?; + } + if crate::config_source::load_state(&tx, &record.workspace.workspace_id)?.is_none() { + let state = crate::config_source::initial_state()?; + crate::config_source::insert_materialized_state( + &tx, + &record.workspace.workspace_id, + &state, + &record.workspace.created_at, + )?; + } + for resource_kind in ["ticket", "objective", "worker"] { + tx.execute( + r#"INSERT OR IGNORE INTO workspace_resource_human_key_counters ( + workspace_id, resource_kind, next_sequence + ) VALUES (?1, ?2, 1)"#, + params![record.workspace.workspace_id, resource_kind], + )?; + } + let config_revision = crate::config_source::load_state( + &tx, + &record.workspace.workspace_id, + )? + .ok_or_else(|| Error::Store("Workspace config is missing".to_string()))? + .snapshot + .revision; + tx.execute( + r#"INSERT INTO workspace_create_operations ( + operation_key, request_fingerprint, workspace_id, created_at + ) VALUES (?1, ?2, ?3, ?4)"#, + params![ + record.operation_key, + record.request_fingerprint, + record.workspace.workspace_id, + record.workspace.created_at, + ], + )?; + tx.commit()?; + Ok(WorkspaceBootstrapResult { + workspace: record.workspace.clone(), + repository: record.repository.clone(), + config_revision, + replayed: false, + }) + }) + } + async fn get_trusted_runtime(&self, runtime_id: &str) -> Result> { self.with_conn(|conn| { conn.query_row( - r#"SELECT runtime_id, display_name, base_url, public_key, created_at, updated_at, revoked_at + r#"SELECT runtime_id, workspace_id, display_name, base_url, public_key, created_at, updated_at, revoked_at FROM trusted_runtime_records WHERE runtime_id = ?1"#, params![runtime_id], read_trusted_runtime_record, @@ -3948,12 +4132,13 @@ fn account_select_sql(where_clause: &str) -> String { fn read_trusted_runtime_record(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(TrustedRuntimeRecord { runtime_id: row.get(0)?, - display_name: row.get(1)?, - base_url: row.get(2)?, - public_key: row.get(3)?, - created_at: row.get(4)?, - updated_at: row.get(5)?, - revoked_at: row.get(6)?, + workspace_id: row.get(1)?, + display_name: row.get(2)?, + base_url: row.get(3)?, + public_key: row.get(4)?, + created_at: row.get(5)?, + updated_at: row.get(6)?, + revoked_at: row.get(7)?, }) } @@ -5129,6 +5314,26 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id Ok(()) } +fn create_workspace_catalog_operations(conn: &Connection) -> Result<()> { + conn.execute_batch( + r#" + ALTER TABLE trusted_runtime_records + ADD COLUMN workspace_id TEXT REFERENCES workspaces(workspace_id) ON DELETE RESTRICT; + CREATE INDEX idx_trusted_runtime_records_workspace + ON trusted_runtime_records(workspace_id, revoked_at, runtime_id); + + CREATE TABLE workspace_create_operations ( + operation_key TEXT PRIMARY KEY, + request_fingerprint TEXT NOT NULL, + workspace_id TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL, + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); + "#, + )?; + Ok(()) +} + fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { if current_schema_version(conn)? < 39 { return Ok(()); @@ -7249,7 +7454,7 @@ mod tests { let before = std::fs::read(&path).unwrap(); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, 36); - assert_eq!(plan.target_schema_version, 39); + assert_eq!(plan.target_schema_version, 40); assert!(plan.migration_required); assert_eq!(plan.worker_count, 1); assert_eq!(plan.mappings[0].legacy_worker_id, 7); @@ -7263,7 +7468,7 @@ mod tests { store .with_conn(|conn| { assert!(table_exists(conn, "worker_diagnostics_archives")?); - assert_eq!(current_schema_version(conn)?, 39); + assert_eq!(current_schema_version(conn)?, 40); Ok(()) }) .unwrap(); @@ -7342,7 +7547,7 @@ mod tests { ), ] ); - assert_eq!(current_schema_version(&conn).unwrap(), 39); + assert_eq!(current_schema_version(&conn).unwrap(), 40); let foreign_key_error: Option = conn .query_row("PRAGMA foreign_key_check", [], |row| row.get(0)) .optional() @@ -7471,7 +7676,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 39); + assert_eq!(current_schema_version(&conn).unwrap(), 40); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -7589,7 +7794,7 @@ INSERT INTO worker_orphan_diagnostics ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 39); + assert_eq!(current_schema_version(&conn).unwrap(), 40); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -7622,7 +7827,7 @@ CREATE TABLE flow_events (event_id TEXT PRIMARY KEY); apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 39); + assert_eq!(current_schema_version(&conn).unwrap(), 40); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -7689,7 +7894,7 @@ INSERT INTO worker_workdir_attachment_reservations ( apply_migrations(&conn).unwrap(); - assert_eq!(current_schema_version(&conn).unwrap(), 39); + assert_eq!(current_schema_version(&conn).unwrap(), 40); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -7867,7 +8072,7 @@ INSERT INTO workdir_registry ( let db = dir.path().join("control-plane.sqlite"); let store = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 39); + assert_eq!(store.schema_version().await.unwrap(), 40); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -7884,7 +8089,7 @@ INSERT INTO workdir_registry ( store.upsert_workspace(&record).await.unwrap(); let reopened = SqliteWorkspaceStore::open(&db).unwrap(); - assert_eq!(reopened.schema_version().await.unwrap(), 39); + assert_eq!(reopened.schema_version().await.unwrap(), 40); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -8383,13 +8588,13 @@ INSERT INTO worker_registry ( configure_sqlite(&conn).unwrap(); apply_migrations(&conn).unwrap(); conn.execute( - "INSERT INTO __yoi_schema_migrations (version, name) VALUES (40, 'future')", + "INSERT INTO __yoi_schema_migrations (version, name) VALUES (41, 'future')", [], ) .unwrap(); let error = apply_migrations(&conn).unwrap_err().to_string(); - assert!(error.contains("schema version 40 is newer"), "{error}"); + assert!(error.contains("schema version 41 is newer"), "{error}"); assert!(error.contains("refusing to serve"), "{error}"); } @@ -8587,6 +8792,41 @@ VALUES ('workspace-b', 'ticket-b', 'related', 'ticket-a', NULL, 'tester', '2026- assert_eq!(foreign_keys_enabled, 1); } + #[test] + fn schema_v40_adds_workspace_create_operations_and_fail_closed_runtime_assignment() { + let mut conn = Connection::open_in_memory().unwrap(); + configure_sqlite(&conn).unwrap(); + apply_migrations_through(&conn, 39).unwrap(); + let now = "2026-01-01T00:00:00Z"; + conn.execute( + r#"INSERT INTO workspaces ( + workspace_id, display_name, state, created_at, updated_at + ) VALUES ('workspace-a', 'Workspace A', 'active', ?1, ?1)"#, + params![now], + ) + .unwrap(); + conn.execute( + r#"INSERT INTO trusted_runtime_records ( + runtime_id, display_name, base_url, public_key, created_at, updated_at + ) VALUES ('runtime-a', 'Runtime A', 'http://runtime-a.test', 'key', ?1, ?1)"#, + params![now], + ) + .unwrap(); + + apply_migrations(&mut conn).unwrap(); + + assert_eq!(current_schema_version(&conn).unwrap(), 40); + let workspace_id: Option = conn + .query_row( + "SELECT workspace_id FROM trusted_runtime_records WHERE runtime_id = 'runtime-a'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(workspace_id, None); + assert!(table_exists(&conn, "workspace_create_operations").unwrap()); + } + #[test] fn workspace_resource_fk_migration_preflights_and_enforces_composite_identity() { let conn = Connection::open_in_memory().unwrap(); @@ -9076,7 +9316,7 @@ INSERT INTO ticket_worker_assignment_events ( .unwrap(); let store = SqliteWorkspaceStore::from_connection(conn).unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 39); + assert_eq!(store.schema_version().await.unwrap(), 40); store .with_conn(|conn| { @@ -9265,7 +9505,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn repository_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 39); + assert_eq!(store.schema_version().await.unwrap(), 40); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -9331,7 +9571,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 39); + assert_eq!(store.schema_version().await.unwrap(), 40); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -9722,7 +9962,7 @@ CREATE TABLE ticket_assignment_operations ( #[tokio::test] async fn account_and_login_records_round_trip() { let store = SqliteWorkspaceStore::in_memory().unwrap(); - assert_eq!(store.schema_version().await.unwrap(), 39); + assert_eq!(store.schema_version().await.unwrap(), 40); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), @@ -9908,6 +10148,7 @@ CREATE TABLE ticket_assignment_operations ( store .upsert_trusted_runtime(&TrustedRuntimeRecord { runtime_id: "runtime-a".to_string(), + workspace_id: None, display_name: "Runtime A".to_string(), base_url: "https://runtime.invalid".to_string(), public_key: "public-key".to_string(), diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs new file mode 100644 index 00000000..d0d3ed66 --- /dev/null +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -0,0 +1,321 @@ +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use chrono::{SecondsFormat, Utc}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use crate::store::{ + ControlPlaneStore, RepositoryRecord, WorkspaceBootstrapRecord, WorkspaceRecord, +}; +use crate::{Error, Result}; + +const DEFAULT_REPOSITORY_ID: &str = "main"; +const MAX_DISPLAY_NAME_BYTES: usize = 200; +const MAX_OPERATION_KEY_BYTES: usize = 200; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct InitialRepositoryIntent { + pub uri: String, + #[serde(default)] + pub display_name: Option, + #[serde(default)] + pub default_ref: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceCreateRequest { + pub operation_key: String, + pub display_name: String, + pub repository: InitialRepositoryIntent, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +pub struct WorkspaceCreateResponse { + pub workspace: WorkspaceRecord, + pub repository: RepositoryRecord, + pub config_revision: u64, + pub request_fingerprint: String, + pub replayed: bool, +} + +#[derive(Clone)] +pub struct WorkspaceCatalogService { + store: Arc, +} + +impl WorkspaceCatalogService { + pub fn new(store: Arc) -> Self { + Self { store } + } + + pub fn list( + &self, + owner_account_id: Option<&str>, + limit: usize, + ) -> Result> { + let limit = limit.clamp(1, 200); + Ok(self + .store + .list_workspaces()? + .into_iter() + .filter(|workspace| { + workspace.owner_account_id.is_none() + || owner_account_id + .is_some_and(|owner| workspace.owner_account_id.as_deref() == Some(owner)) + }) + .take(limit) + .collect()) + } + + pub fn create( + &self, + request: WorkspaceCreateRequest, + owner_account_id: Option, + ) -> Result { + self.create_with_workspace_id(request, owner_account_id, None) + } + + pub fn create_with_workspace_id( + &self, + request: WorkspaceCreateRequest, + owner_account_id: Option, + requested_workspace_id: Option, + ) -> Result { + let operation_key = normalize_required( + "operation_key", + request.operation_key, + MAX_OPERATION_KEY_BYTES, + )?; + let display_name = + normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?; + let repository_path = validate_repository_uri(&request.repository.uri)?; + let repository_uri = repository_path.to_string_lossy().into_owned(); + let repository_name = request + .repository + .display_name + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("Main repository") + .to_string(); + let default_ref = request + .repository + .default_ref + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("HEAD") + .to_string(); + let requested_workspace_id = requested_workspace_id + .map(|value| { + Uuid::parse_str(value.trim()) + .map(|id| id.to_string()) + .map_err(|_| Error::InvalidInput("workspace_id must be a UUID".to_string())) + }) + .transpose()?; + let workspace_id = requested_workspace_id + .clone() + .unwrap_or_else(|| Uuid::now_v7().to_string()); + let fingerprint = workspace_create_fingerprint( + requested_workspace_id.as_deref(), + &display_name, + owner_account_id.as_deref(), + &repository_uri, + &repository_name, + &default_ref, + ); + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + let result = self + .store + .create_workspace_bootstrap(&WorkspaceBootstrapRecord { + operation_key, + request_fingerprint: fingerprint.clone(), + workspace: WorkspaceRecord { + workspace_id: workspace_id.clone(), + owner_account_id, + display_name, + state: "active".to_string(), + created_at: now.clone(), + updated_at: now.clone(), + }, + repository: RepositoryRecord { + workspace_id, + repository_id: DEFAULT_REPOSITORY_ID.to_string(), + name: repository_name, + kind: "git".to_string(), + provider: Some("git".to_string()), + uri: repository_uri, + default_ref: Some(default_ref), + auth_ref_kind: None, + auth_ref_key: None, + created_at: now.clone(), + updated_at: now, + }, + })?; + Ok(WorkspaceCreateResponse { + workspace: result.workspace, + repository: result.repository, + config_revision: result.config_revision, + request_fingerprint: fingerprint, + replayed: result.replayed, + }) + } +} + +fn normalize_required(field: &str, value: String, max_bytes: usize) -> Result { + let value = value.trim(); + if value.is_empty() || value.len() > max_bytes { + return Err(Error::InvalidInput(format!( + "{field} must be between 1 and {max_bytes} bytes" + ))); + } + Ok(value.to_string()) +} + +fn validate_repository_uri(uri: &str) -> Result { + let uri = uri.trim(); + if uri.is_empty() || uri.contains("://") { + return Err(Error::InvalidInput( + "initial repository uri must be an absolute server-local path".to_string(), + )); + } + let path = Path::new(uri); + if !path.is_absolute() { + return Err(Error::InvalidInput( + "initial repository uri must be an absolute server-local path".to_string(), + )); + } + let path = path.canonicalize().map_err(|error| { + Error::InvalidInput(format!("initial repository path is unavailable: {error}")) + })?; + if !path.is_dir() { + return Err(Error::InvalidInput( + "initial repository path must be a directory".to_string(), + )); + } + let normal_git = path.join(".git").exists(); + let bare_git = path.join("HEAD").is_file() && path.join("objects").is_dir(); + if !normal_git && !bare_git { + return Err(Error::InvalidInput( + "initial repository path is not a Git repository".to_string(), + )); + } + Ok(path) +} + +fn workspace_create_fingerprint( + requested_workspace_id: Option<&str>, + display_name: &str, + owner_account_id: Option<&str>, + repository_uri: &str, + repository_name: &str, + default_ref: &str, +) -> String { + let payload = serde_json::json!({ + "requested_workspace_id": requested_workspace_id, + "display_name": display_name, + "owner_account_id": owner_account_id, + "repository": { + "repository_id": DEFAULT_REPOSITORY_ID, + "uri": repository_uri, + "display_name": repository_name, + "default_ref": default_ref, + "kind": "git", + } + }); + let mut hasher = Sha256::new(); + hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes")); + let digest = hasher.finalize(); + let encoded = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + format!("sha256:{encoded}") +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::SqliteWorkspaceStore; + + fn git_repository() -> tempfile::TempDir { + let dir = tempfile::tempdir().unwrap(); + std::fs::create_dir(dir.path().join(".git")).unwrap(); + dir + } + + #[tokio::test] + async fn create_is_atomic_and_exact_retries_converge() { + let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let service = WorkspaceCatalogService::new(store.clone()); + let repository = git_repository(); + let request = WorkspaceCreateRequest { + operation_key: "request-1".to_string(), + display_name: "Workspace A".to_string(), + repository: InitialRepositoryIntent { + uri: repository.path().display().to_string(), + display_name: None, + default_ref: None, + }, + }; + + let created = service.create(request.clone(), None).unwrap(); + let replayed = service.create(request, None).unwrap(); + + assert!(!created.replayed); + assert!(replayed.replayed); + assert_eq!( + created.workspace.workspace_id, + replayed.workspace.workspace_id + ); + assert_eq!(store.list_workspaces().unwrap().len(), 1); + assert_eq!( + store + .list_repositories(&created.workspace.workspace_id) + .unwrap() + .len(), + 1 + ); + assert!( + store + .load_workspace_config(&created.workspace.workspace_id) + .unwrap() + .is_some() + ); + } + + #[tokio::test] + async fn idempotency_key_reuse_with_different_payload_is_rejected() { + let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let service = WorkspaceCatalogService::new(store); + let repository = git_repository(); + let mut request = WorkspaceCreateRequest { + operation_key: "request-1".to_string(), + display_name: "Workspace A".to_string(), + repository: InitialRepositoryIntent { + uri: repository.path().display().to_string(), + display_name: None, + default_ref: None, + }, + }; + service.create(request.clone(), None).unwrap(); + request.display_name = "Workspace B".to_string(); + + let error = service.create(request, None).unwrap_err().to_string(); + assert!(error.contains("different input"), "{error}"); + } + + #[test] + fn repository_intent_rejects_remote_and_non_git_paths() { + let remote = validate_repository_uri("https://example.test/repo.git").unwrap_err(); + assert!(remote.to_string().contains("server-local path")); + + let dir = tempfile::tempdir().unwrap(); + let non_git = validate_repository_uri(&dir.path().display().to_string()).unwrap_err(); + assert!(non_git.to_string().contains("not a Git repository")); + } +} From 4b8dc302ee2ae49b1885b9b45d37697cee2a33ed Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 17:52:23 +0900 Subject: [PATCH 13/22] fix: fence workspace bootstrap and resource routing --- crates/workspace-server/src/server.rs | 133 +++++++++++++----- crates/workspace-server/src/store.rs | 17 +++ .../workspace-server/src/workspace_catalog.rs | 75 +++++++++- 3 files changed, 187 insertions(+), 38 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 3642531f..b63decb3 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -842,25 +842,19 @@ async fn create_server_workspace( headers: HeaderMap, Json(request): Json, ) -> Response { - let owner_account_id = match resolve_server_actor(&api, &headers).await { - Ok(Some(actor)) => Some(actor.account_id), - Ok(None) if api.template.allow_local_workspace_bootstrap => { - match api.store.list_workspaces() { - Ok(workspaces) if workspaces.is_empty() => None, - Ok(_) => { - return forbidden_server_response( - "Workspace creation requires an authenticated owner", - ); - } - Err(error) => return server_error_response(error), - } - } + let (owner_account_id, local_bootstrap) = match resolve_server_actor(&api, &headers).await { + Ok(Some(actor)) => (Some(actor.account_id), false), + Ok(None) if api.template.allow_local_workspace_bootstrap => (None, true), Ok(None) => { return forbidden_server_response("Workspace creation requires an authenticated owner"); } Err(error) => return server_error_response(error), }; - let created = match api.catalog.create(request, owner_account_id) { + let created = match if local_bootstrap { + api.catalog.create_first_ownerless(request) + } else { + api.catalog.create(request, owner_account_id) + } { Ok(created) => created, Err(error) => return server_error_response(error), }; @@ -935,7 +929,10 @@ fn is_server_global_forward(path: &str) -> bool { fn scoped_workspace_id(path: &str) -> Option<&str> { let mut segments = path.trim_start_matches('/').split('/'); match (segments.next(), segments.next(), segments.next()) { - (Some("api"), Some("w"), Some(workspace_id)) if !workspace_id.is_empty() => { + (Some("api"), Some("w"), Some(workspace_id)) + | (Some("internal"), Some("w"), Some(workspace_id)) + if !workspace_id.is_empty() => + { Some(workspace_id) } (Some("w"), Some(workspace_id), _) if !workspace_id.is_empty() => Some(workspace_id), @@ -1963,8 +1960,8 @@ pub fn build_router(api: WorkspaceApi) -> Router { post(scoped_test_remote_runtime_connection), ) .route( - "/internal/runtime/resources/fetch", - post(post_internal_runtime_resource_fetch), + "/internal/w/{workspace_id}/runtime/resources/fetch", + post(scoped_post_internal_runtime_resource_fetch), ) .route("/api/companion/status", get(get_companion_status)) .route( @@ -9792,13 +9789,20 @@ fn browser_worker_response_from_summary( }) } -async fn post_internal_runtime_resource_fetch( +async fn scoped_post_internal_runtime_resource_fetch( State(api): State, + AxumPath(workspace_id): AxumPath, Json(request): Json, ) -> std::result::Result< Json, (StatusCode, Json), > { + if workspace_id != api.workspace_id() { + return Err(( + StatusCode::NOT_FOUND, + Json(BackendResourceError::MissingResource), + )); + } api.resource_broker .fetch_profile_source_archive(request) .map(Json) @@ -14466,6 +14470,37 @@ mod tests { assert_eq!(b["workspace_id"], workspace_b.workspace.workspace_id); assert_eq!(b["display_name"], "Workspace B"); + let handle = missing_resource_handle(); + let resource_response = app + .clone() + .oneshot( + Request::post(format!( + "/internal/w/{}/runtime/resources/fetch", + workspace_b.workspace.workspace_id + )) + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from( + serde_json::to_vec(&BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + runtime_id: "runtime-test".to_string(), + worker_id: None, + handle, + }) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resource_response.status(), StatusCode::NOT_FOUND); + let resource_error: BackendResourceError = serde_json::from_slice( + &to_bytes(resource_response.into_body(), usize::MAX) + .await + .unwrap(), + ) + .unwrap(); + assert_eq!(resource_error, BackendResourceError::MissingResource); + let missing = app .oneshot( Request::builder() @@ -14519,6 +14554,7 @@ mod tests { assert_eq!(workspace["display_name"], "Created Workspace"); let replayed = app + .clone() .oneshot( Request::builder() .method(Method::POST) @@ -14529,10 +14565,29 @@ mod tests { ) .await .unwrap(); - // Local bootstrap authority is consumed after the first Workspace; even - // an exact HTTP retry must authenticate rather than creating another - // ownerless Workspace accidentally. - assert_eq!(replayed.status(), StatusCode::FORBIDDEN); + assert_eq!(replayed.status(), StatusCode::OK); + + let second_payload = json!({ + "operation_key": "bootstrap-2", + "display_name": "Second Ownerless Workspace", + "repository": { + "uri": repository, + "display_name": "Repository", + "default_ref": "HEAD" + } + }); + let second = app + .oneshot( + Request::builder() + .method(Method::POST) + .uri("/api/workspaces") + .header(axum::http::header::CONTENT_TYPE, "application/json") + .body(Body::from(second_payload.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(second.status(), StatusCode::CONFLICT); } #[test] @@ -14545,6 +14600,10 @@ mod tests { scoped_workspace_id("/w/workspace-b/workers"), Some("workspace-b") ); + assert_eq!( + scoped_workspace_id("/internal/w/workspace-c/runtime/resources/fetch"), + Some("workspace-c") + ); assert_eq!(scoped_workspace_id("/api/workspaces"), None); assert_eq!(scoped_workspace_id("/api/workspace"), None); } @@ -17146,20 +17205,20 @@ mod tests { let handle = missing_resource_handle(); let response = app .oneshot( - Request::post("/internal/runtime/resources/fetch") - .header("content-type", "application/json") - .body(Body::from( - serde_json::to_vec( - &worker_runtime::resource::BackendResourceFetchRequest { - audit_correlation_id: handle.audit_correlation_id.clone(), - runtime_id: "runtime-test".to_string(), - worker_id: None, - handle, - }, - ) - .unwrap(), - )) + Request::post(format!( + "/internal/w/{TEST_WORKSPACE_ID}/runtime/resources/fetch" + )) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&worker_runtime::resource::BackendResourceFetchRequest { + audit_correlation_id: handle.audit_correlation_id.clone(), + runtime_id: "runtime-test".to_string(), + worker_id: None, + handle, + }) .unwrap(), + )) + .unwrap(), ) .await .unwrap(); @@ -17182,7 +17241,7 @@ mod tests { let archive = test_profile_archive(); let runtime_id = "runtime-test"; let handle = broker.issue_profile_source_archive_handle( - "workspace-test", + TEST_WORKSPACE_ID, crate::resource_broker::BackendResourceTarget::Runtime(runtime_id), archive, ); @@ -17191,7 +17250,7 @@ mod tests { let addr = listener.local_addr().unwrap(); let server = tokio::spawn(async move { axum::serve(listener, app).await.unwrap() }); let client = worker_runtime::resource::HttpBackendResourceClient::new( - format!("http://{addr}/internal/runtime/resources/fetch"), + format!("http://{addr}/internal/w/{TEST_WORKSPACE_ID}/runtime/resources/fetch"), None, ); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 01a8f1b0..f1cf1a68 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -275,6 +275,9 @@ pub struct RepositoryRecord { pub struct WorkspaceBootstrapRecord { pub operation_key: String, pub request_fingerprint: String, + /// When true, the transaction must prove that no Workspace exists before + /// it inserts this ownerless local-bootstrap Workspace. + pub require_empty_catalog: bool, pub workspace: WorkspaceRecord, pub repository: RepositoryRecord, } @@ -1444,6 +1447,20 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }); } + if record.require_empty_catalog { + let workspace_exists = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM workspaces LIMIT 1)", + [], + |row| row.get::<_, bool>(0), + )?; + if workspace_exists { + return Err(Error::WorkspaceConfigConflict( + "ownerless local bootstrap is available only while the Workspace catalog is empty" + .to_string(), + )); + } + } + if let Some(existing) = tx .query_row( r#"SELECT workspace_id, owner_account_id, display_name, state, created_at, updated_at diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index d0d3ed66..adf67b6f 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -76,7 +76,14 @@ impl WorkspaceCatalogService { request: WorkspaceCreateRequest, owner_account_id: Option, ) -> Result { - self.create_with_workspace_id(request, owner_account_id, None) + self.create_internal(request, owner_account_id, None, false) + } + + pub fn create_first_ownerless( + &self, + request: WorkspaceCreateRequest, + ) -> Result { + self.create_internal(request, None, None, true) } pub fn create_with_workspace_id( @@ -84,6 +91,16 @@ impl WorkspaceCatalogService { request: WorkspaceCreateRequest, owner_account_id: Option, requested_workspace_id: Option, + ) -> Result { + self.create_internal(request, owner_account_id, requested_workspace_id, false) + } + + fn create_internal( + &self, + request: WorkspaceCreateRequest, + owner_account_id: Option, + requested_workspace_id: Option, + require_empty_catalog: bool, ) -> Result { let operation_key = normalize_required( "operation_key", @@ -134,6 +151,7 @@ impl WorkspaceCatalogService { .create_workspace_bootstrap(&WorkspaceBootstrapRecord { operation_key, request_fingerprint: fingerprint.clone(), + require_empty_catalog, workspace: WorkspaceRecord { workspace_id: workspace_id.clone(), owner_account_id, @@ -288,6 +306,61 @@ mod tests { ); } + #[test] + fn concurrent_ownerless_bootstrap_commits_exactly_one_workspace() { + let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let service = WorkspaceCatalogService::new(store.clone()); + let repository_a = git_repository(); + let repository_b = git_repository(); + let requests = [ + WorkspaceCreateRequest { + operation_key: "bootstrap-a".to_string(), + display_name: "Workspace A".to_string(), + repository: InitialRepositoryIntent { + uri: repository_a.path().display().to_string(), + display_name: None, + default_ref: None, + }, + }, + WorkspaceCreateRequest { + operation_key: "bootstrap-b".to_string(), + display_name: "Workspace B".to_string(), + repository: InitialRepositoryIntent { + uri: repository_b.path().display().to_string(), + display_name: None, + default_ref: None, + }, + }, + ]; + let barrier = Arc::new(std::sync::Barrier::new(2)); + let results = std::thread::scope(|scope| { + requests + .into_iter() + .map(|request| { + let service = service.clone(); + let barrier = barrier.clone(); + scope.spawn(move || { + barrier.wait(); + service.create_first_ownerless(request) + }) + }) + .collect::>() + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>() + }); + + assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); + assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); + assert_eq!(store.list_workspaces().unwrap().len(), 1); + let error = results + .into_iter() + .find_map(Result::err) + .unwrap() + .to_string(); + assert!(error.contains("catalog is empty"), "{error}"); + } + #[tokio::test] async fn idempotency_key_reuse_with_different_payload_is_rejected() { let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); From 17abe1c40c9b8717eb19c799d7e86e75134bb5c7 Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 18:34:18 +0900 Subject: [PATCH 14/22] feat: add scoped TUI workspace selection --- crates/client/src/backend_runtime.rs | 159 ++++++++++---- crates/client/src/backend_workspace.rs | 161 ++++++++++++++ crates/client/src/lib.rs | 6 + crates/client/src/target.rs | 30 +++ crates/client/tests/backend_workspace.rs | 36 ++++ crates/tui/src/backend_worker_picker.rs | 160 ++++++++------ crates/tui/src/backend_workspace_picker.rs | 240 +++++++++++++++++++++ crates/tui/src/lib.rs | 1 + 8 files changed, 682 insertions(+), 111 deletions(-) create mode 100644 crates/client/src/backend_workspace.rs create mode 100644 crates/client/tests/backend_workspace.rs create mode 100644 crates/tui/src/backend_workspace_picker.rs diff --git a/crates/client/src/backend_runtime.rs b/crates/client/src/backend_runtime.rs index 7f22b21d..9c810dcc 100644 --- a/crates/client/src/backend_runtime.rs +++ b/crates/client/src/backend_runtime.rs @@ -14,6 +14,8 @@ pub struct BackendRuntimeTarget { /// Workspace Backend API root URL, for example `http://127.0.0.1:8787`. /// This is intentionally the Backend endpoint, not a Runtime endpoint. pub base_url: String, + /// Workspace identity used for every Worker lifecycle and protocol operation. + pub workspace_id: String, /// Backend-owned Runtime identity used as path authority. pub runtime_id: String, /// Backend-owned Worker identity used as path authority. @@ -23,11 +25,13 @@ pub struct BackendRuntimeTarget { impl BackendRuntimeTarget { pub fn new( base_url: impl Into, + workspace_id: impl Into, runtime_id: impl Into, worker_id: impl Into, ) -> Self { Self { base_url: base_url.into(), + workspace_id: workspace_id.into(), runtime_id: runtime_id.into(), worker_id: worker_id.into(), } @@ -57,6 +61,36 @@ impl BackendRuntimeListTarget { runtime_id, } } + + pub fn select_workspace(&mut self, workspace_id: impl Into) { + self.workspace_id = Some(workspace_id.into()); + } + + pub fn clear_workspace(&mut self) { + self.workspace_id = None; + } + + pub fn workspace_id(&self) -> Option<&str> { + self.workspace_id.as_deref() + } + + pub fn runtime_target( + &self, + runtime_id: impl Into, + worker_id: impl Into, + ) -> Result { + let workspace_id = self.workspace_id.clone().ok_or_else(|| { + BackendRuntimeClientError::InvalidTarget( + "workspace_id is required before selecting a Backend worker".to_string(), + ) + })?; + Ok(BackendRuntimeTarget::new( + self.base_url.clone(), + workspace_id, + runtime_id, + worker_id, + )) + } } #[derive(Debug, Clone, Deserialize)] @@ -186,7 +220,13 @@ pub async fn list_backend_workers( validate_list_target(target)?; let http = reqwest::Client::new(); if let Some(runtime_id) = target.runtime_id.as_deref() { - let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id); + let path = backend_runtime_workers_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + runtime_id, + ); let url = join_base_and_path(&target.base_url, &path); return Ok(http .get(url) @@ -197,7 +237,12 @@ pub async fn list_backend_workers( .await?); } - let runtime_path = backend_runtimes_path(target.workspace_id.as_deref()); + let runtime_path = backend_runtimes_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + ); let runtime_url = join_base_and_path(&target.base_url, &runtime_path); let runtimes = http .get(runtime_url) @@ -210,8 +255,13 @@ pub async fn list_backend_workers( let mut items = Vec::new(); let mut diagnostics = runtimes.diagnostics; for runtime in runtimes.items { - let path = - backend_runtime_workers_path(target.workspace_id.as_deref(), &runtime.runtime_id); + let path = backend_runtime_workers_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + &runtime.runtime_id, + ); let url = join_base_and_path(&target.base_url, &path); match http .get(url) @@ -256,7 +306,13 @@ pub async fn list_backend_stopped_workers( )); }; let http = reqwest::Client::new(); - let path = backend_runtime_workers_path(target.workspace_id.as_deref(), runtime_id); + let path = backend_runtime_workers_path( + target + .workspace_id + .as_deref() + .expect("validated Backend Workspace scope"), + runtime_id, + ); let url = join_base_and_path(&target.base_url, &format!("{path}?status=stopped")); Ok(http .get(url) @@ -272,7 +328,11 @@ pub async fn restore_backend_worker( ) -> Result { validate_target(target)?; let http = reqwest::Client::new(); - let path = backend_runtime_worker_restore_path(None, &target.runtime_id, &target.worker_id); + let path = backend_runtime_worker_restore_path( + &target.workspace_id, + &target.runtime_id, + &target.worker_id, + ); let url = join_base_and_path(&target.base_url, &path); Ok(http .post(url) @@ -440,6 +500,11 @@ fn validate_target(target: &BackendRuntimeTarget) -> Result<(), BackendRuntimeCl "Backend API base URL must start with http:// or https://".to_string(), )); } + if target.workspace_id.is_empty() { + return Err(BackendRuntimeClientError::InvalidTarget( + "workspace_id is required".to_string(), + )); + } if target.runtime_id.is_empty() { return Err(BackendRuntimeClientError::InvalidTarget( "runtime_id is required".to_string(), @@ -466,10 +531,18 @@ fn validate_list_target( "Backend API base URL must start with http:// or https://".to_string(), )); } - if target.workspace_id.as_deref().is_some_and(str::is_empty) { - return Err(BackendRuntimeClientError::InvalidTarget( - "workspace_id must not be empty when provided".to_string(), - )); + match target.workspace_id.as_deref() { + Some("") => { + return Err(BackendRuntimeClientError::InvalidTarget( + "workspace_id must not be empty".to_string(), + )); + } + None => { + return Err(BackendRuntimeClientError::InvalidTarget( + "workspace selection is required before listing Backend workers".to_string(), + )); + } + Some(_) => {} } if target.runtime_id.as_deref().is_some_and(str::is_empty) { return Err(BackendRuntimeClientError::InvalidTarget( @@ -479,47 +552,35 @@ fn validate_list_target( Ok(()) } -fn backend_runtimes_path(workspace_id: Option<&str>) -> String { - match workspace_id { - Some(workspace_id) => format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)), - None => "/api/runtimes".to_string(), - } +fn backend_runtimes_path(workspace_id: &str) -> String { + format!("/api/w/{}/runtimes", path_segment_encode(workspace_id)) } -fn backend_runtime_workers_path(workspace_id: Option<&str>, runtime_id: &str) -> String { - match workspace_id { - Some(workspace_id) => format!( - "/api/w/{}/runtimes/{}/workers", - path_segment_encode(workspace_id), - path_segment_encode(runtime_id) - ), - None => format!("/api/runtimes/{}/workers", path_segment_encode(runtime_id)), - } +fn backend_runtime_workers_path(workspace_id: &str, runtime_id: &str) -> String { + format!( + "/api/w/{}/runtimes/{}/workers", + path_segment_encode(workspace_id), + path_segment_encode(runtime_id) + ) } fn backend_runtime_worker_restore_path( - workspace_id: Option<&str>, + workspace_id: &str, runtime_id: &str, worker_id: &str, ) -> String { - match workspace_id { - Some(workspace_id) => format!( - "/api/w/{}/runtimes/{}/workers/{}/restore", - path_segment_encode(workspace_id), - path_segment_encode(runtime_id), - path_segment_encode(worker_id) - ), - None => format!( - "/api/runtimes/{}/workers/{}/restore", - path_segment_encode(runtime_id), - path_segment_encode(worker_id) - ), - } + format!( + "/api/w/{}/runtimes/{}/workers/{}/restore", + path_segment_encode(workspace_id), + path_segment_encode(runtime_id), + path_segment_encode(worker_id) + ) } fn protocol_ws_url(target: &BackendRuntimeTarget) -> String { let path = format!( - "/api/runtimes/{}/workers/{}/protocol/ws", + "/api/w/{}/runtimes/{}/workers/{}/protocol/ws", + path_segment_encode(&target.workspace_id), path_segment_encode(&target.runtime_id), path_segment_encode(&target.worker_id) ); @@ -573,11 +634,15 @@ mod tests { #[test] fn protocol_url_uses_backend_runtime_worker_identity() { - let target = - BackendRuntimeTarget::new("http://127.0.0.1:8787/", "runtime/one", "worker one"); + let target = BackendRuntimeTarget::new( + "http://127.0.0.1:8787/", + "workspace alpha", + "runtime/one", + "worker one", + ); assert_eq!( protocol_ws_url(&target), - "ws://127.0.0.1:8787/api/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws" + "ws://127.0.0.1:8787/api/w/workspace%20alpha/runtimes/runtime%2Fone/workers/worker%20one/protocol/ws" ); } @@ -622,8 +687,8 @@ mod tests { } #[test] - fn workers_path_can_be_workspace_scoped_for_status_queries() { - let path = backend_runtime_workers_path(Some("team main"), "runtime/one"); + fn workers_path_requires_workspace_scope_for_status_queries() { + let path = backend_runtime_workers_path("team main", "runtime/one"); assert_eq!( format!("{path}?status=stopped"), "/api/w/team%20main/runtimes/runtime%2Fone/workers?status=stopped" @@ -631,10 +696,10 @@ mod tests { } #[test] - fn restore_worker_path_uses_backend_runtime_worker_identity() { + fn restore_worker_path_requires_workspace_scope() { assert_eq!( - backend_runtime_worker_restore_path(None, "runtime/one", "worker one"), - "/api/runtimes/runtime%2Fone/workers/worker%20one/restore" + backend_runtime_worker_restore_path("team main", "runtime/one", "worker one"), + "/api/w/team%20main/runtimes/runtime%2Fone/workers/worker%20one/restore" ); } } diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs new file mode 100644 index 00000000..c6dee233 --- /dev/null +++ b/crates/client/src/backend_workspace.rs @@ -0,0 +1,161 @@ +use serde::{Deserialize, Serialize}; +use std::fmt; + +const DEFAULT_WORKSPACE_LIMIT: usize = 200; + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct BackendWorkspace { + pub workspace_id: String, + pub owner_account_id: Option, + pub display_name: String, + pub state: String, + pub created_at: String, + pub updated_at: String, +} + +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CreateBackendWorkspaceRequest { + pub operation_key: String, + pub display_name: String, + pub repository: CreateBackendWorkspaceRepository, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct CreateBackendWorkspaceRepository { + pub uri: String, + pub display_name: Option, + pub default_ref: Option, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CreateBackendWorkspaceResponse { + pub workspace: BackendWorkspace, + pub repository: CreateBackendWorkspaceRepositoryRecord, + pub config_revision: u64, + pub request_fingerprint: String, + pub replayed: bool, +} + +#[derive(Debug, Clone, Deserialize, PartialEq, Eq)] +pub struct CreateBackendWorkspaceRepositoryRecord { + pub workspace_id: String, + pub repository_id: String, + pub name: String, + pub kind: String, + pub uri: String, + pub default_ref: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BackendWorkspaceCatalogTarget { + pub base_url: String, +} + +impl BackendWorkspaceCatalogTarget { + pub fn new(base_url: impl Into) -> Self { + Self { + base_url: base_url.into(), + } + } +} + +#[derive(Debug)] +pub enum BackendWorkspaceClientError { + InvalidTarget(String), + RequestFailed { status: u16, message: String }, + Http(reqwest::Error), +} + +impl fmt::Display for BackendWorkspaceClientError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidTarget(message) => f.write_str(message), + Self::RequestFailed { status, message } => { + write!(f, "Backend request failed with HTTP {status}: {message}") + } + Self::Http(error) => write!(f, "{error}"), + } + } +} + +impl std::error::Error for BackendWorkspaceClientError {} + +impl From for BackendWorkspaceClientError { + fn from(error: reqwest::Error) -> Self { + Self::Http(error) + } +} + +pub async fn list_backend_workspaces( + target: &BackendWorkspaceCatalogTarget, +) -> Result, BackendWorkspaceClientError> { + validate_target(target)?; + let url = format!( + "{}/api/workspaces?limit={DEFAULT_WORKSPACE_LIMIT}", + target.base_url.trim_end_matches('/') + ); + let response = reqwest::Client::new().get(url).send().await?; + let response = require_success(response).await?; + Ok(response.json::>().await?) +} + +pub async fn create_backend_workspace( + target: &BackendWorkspaceCatalogTarget, + request: &CreateBackendWorkspaceRequest, +) -> Result { + validate_target(target)?; + let url = format!("{}/api/workspaces", target.base_url.trim_end_matches('/')); + let response = reqwest::Client::new() + .post(url) + .json(request) + .send() + .await?; + let response = require_success(response).await?; + Ok(response.json::().await?) +} + +async fn require_success( + response: reqwest::Response, +) -> Result { + if response.status().is_success() { + return Ok(response); + } + let status = response.status().as_u16(); + let message = response.text().await.unwrap_or_default(); + Err(BackendWorkspaceClientError::RequestFailed { status, message }) +} + +fn validate_target( + target: &BackendWorkspaceCatalogTarget, +) -> Result<(), BackendWorkspaceClientError> { + if !(target.base_url.starts_with("http://") || target.base_url.starts_with("https://")) { + return Err(BackendWorkspaceClientError::InvalidTarget( + "Backend API base URL must start with http:// or https://".to_string(), + )); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn create_request_keeps_operation_key_for_exact_retry() { + let request = CreateBackendWorkspaceRequest { + operation_key: "workspace-create-1".to_string(), + display_name: "Alpha".to_string(), + repository: CreateBackendWorkspaceRepository { + uri: "/srv/repos/alpha".to_string(), + display_name: Some("Main".to_string()), + default_ref: Some("develop".to_string()), + }, + }; + + let retry = request.clone(); + assert_eq!(retry.operation_key, "workspace-create-1"); + assert_eq!(retry, request); + } +} diff --git a/crates/client/src/lib.rs b/crates/client/src/lib.rs index cab1b245..45d51a90 100644 --- a/crates/client/src/lib.rs +++ b/crates/client/src/lib.rs @@ -10,6 +10,7 @@ pub mod backend_auth; pub mod backend_runtime; +pub mod backend_workspace; pub mod runtime_command; pub mod spawn; pub mod target; @@ -28,6 +29,11 @@ pub use backend_runtime::{ BackendWorkerWorkspaceSummary, BackendWorkingDirectorySummary, list_backend_stopped_workers, list_backend_workers, restore_backend_worker, }; +pub use backend_workspace::{ + BackendWorkspace, BackendWorkspaceCatalogTarget, BackendWorkspaceClientError, + CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, + CreateBackendWorkspaceResponse, create_backend_workspace, list_backend_workspaces, +}; pub use runtime_command::WorkerRuntimeCommand; pub use target::{ BackendTarget, Dashboard, LocalTarget, Target, TargetError, TargetKind, WorkerByName, diff --git a/crates/client/src/target.rs b/crates/client/src/target.rs index 595aeff3..1234d459 100644 --- a/crates/client/src/target.rs +++ b/crates/client/src/target.rs @@ -132,6 +132,12 @@ impl TargetError { } } + fn invalid(target: TargetKind, message: impl Into) -> Self { + Self { + message: format!("invalid {target} target: {}", message.into()), + } + } + fn local_runtime_command(error: std::io::Error) -> Self { Self { message: format!("failed to resolve local Worker runtime command: {error}"), @@ -260,9 +266,16 @@ impl Target for BackendTarget { &self, selector: WorkerConnectionSelector, ) -> Result { + let workspace_id = self.workspace_id.clone().ok_or_else(|| { + TargetError::invalid( + self.kind(), + "workspace selection is required before connecting to a Backend Worker", + ) + })?; Ok(WorkerConnection { target: BackendRuntimeTarget::new( self.base_url.clone(), + workspace_id, selector.runtime_id, selector.worker_id, ), @@ -313,10 +326,27 @@ mod tests { .unwrap(); assert_eq!(connection.target.base_url, "http://127.0.0.1:8787"); + assert_eq!(connection.target.workspace_id, "workspace-a"); assert_eq!(connection.target.runtime_id, "runtime-a"); assert_eq!(connection.target.worker_id, "worker-b"); } + #[test] + fn backend_target_rejects_worker_connection_before_workspace_selection() { + let target = BackendTarget::new("http://127.0.0.1:8787", None::); + let error = + match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-b")) { + Ok(_) => panic!("unscoped connection must fail"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("workspace selection is required") + ); + } + #[test] fn backend_target_rejects_local_worker_operations() { let target = BackendTarget::new("http://127.0.0.1:8787", None::); diff --git a/crates/client/tests/backend_workspace.rs b/crates/client/tests/backend_workspace.rs new file mode 100644 index 00000000..64772bd2 --- /dev/null +++ b/crates/client/tests/backend_workspace.rs @@ -0,0 +1,36 @@ +use client::{ + BackendTarget, CreateBackendWorkspaceRepository, CreateBackendWorkspaceRequest, Target, + WorkerConnectionSelector, +}; + +#[test] +fn workspace_creation_request_preserves_operation_key_for_retry() { + let request = CreateBackendWorkspaceRequest { + operation_key: "workspace-create-1".to_string(), + display_name: "Alpha".to_string(), + repository: CreateBackendWorkspaceRepository { + uri: "/srv/repos/alpha".to_string(), + display_name: Some("Main".to_string()), + default_ref: Some("develop".to_string()), + }, + }; + + assert_eq!(request.clone(), request); + assert_eq!(request.operation_key, "workspace-create-1"); +} + +#[test] +fn backend_worker_connection_requires_explicit_workspace_scope() { + let target = BackendTarget::new("http://127.0.0.1:8787", None::); + let error = match target.connect_worker(WorkerConnectionSelector::new("runtime-a", "worker-a")) + { + Ok(_) => panic!("unscoped Backend worker connection must fail"), + Err(error) => error, + }; + + assert!( + error + .to_string() + .contains("workspace selection is required") + ); +} diff --git a/crates/tui/src/backend_worker_picker.rs b/crates/tui/src/backend_worker_picker.rs index c4bd1292..5ff2d925 100644 --- a/crates/tui/src/backend_worker_picker.rs +++ b/crates/tui/src/backend_worker_picker.rs @@ -3,8 +3,8 @@ use std::io; use std::time::Duration; use client::{ - BackendRuntimeListTarget, BackendRuntimeTarget, BackendWorkerSummary, - list_backend_stopped_workers, list_backend_workers, restore_backend_worker, + BackendRuntimeListTarget, BackendWorkerSummary, list_backend_stopped_workers, + list_backend_workers, restore_backend_worker, }; use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEventKind, KeyModifiers}; use ratatui::backend::CrosstermBackend; @@ -14,77 +14,94 @@ use ratatui::text::{Line, Span}; use ratatui::widgets::Paragraph; use ratatui::{Frame, Terminal, TerminalOptions, Viewport}; +use crate::backend_workspace_picker::select_backend_workspace; use crate::console; const MAX_ROWS: usize = 10; const VIEWPORT_LINES: u16 = MAX_ROWS as u16 + 4; pub(crate) async fn run( - target: BackendRuntimeListTarget, + mut target: BackendRuntimeListTarget, include_stopped: bool, ) -> Result<(), Box> { - let mut response = list_backend_workers(&target).await.map_err(|error| { - io::Error::other(format!( - "failed to list Backend runtime workers from {}: {error}", - target.base_url - )) - })?; - if include_stopped { - match list_backend_stopped_workers(&target).await { - Ok(stopped) => { - response.items.extend(stopped.items); - response.diagnostics.extend(stopped.diagnostics); + loop { + if target.workspace_id().is_none() { + let workspace_id = select_backend_workspace(&target.base_url) + .await + .map_err(|error| io::Error::other(error.to_string()))? + .ok_or_else(|| io::Error::other("Backend workspace picker cancelled"))?; + target.select_workspace(workspace_id); + } + let mut response = list_backend_workers(&target).await.map_err(|error| { + io::Error::other(format!( + "failed to list Backend runtime workers from {}: {error}", + target.base_url + )) + })?; + if include_stopped { + match list_backend_stopped_workers(&target).await { + Ok(stopped) => { + response.items.extend(stopped.items); + response.diagnostics.extend(stopped.diagnostics); + } + Err(error) => response.diagnostics.push(client::BackendDiagnostic { + code: "backend_stopped_workers_list_failed".to_string(), + severity: Some("error".to_string()), + message: error.to_string(), + }), } - Err(error) => response.diagnostics.push(client::BackendDiagnostic { - code: "backend_stopped_workers_list_failed".to_string(), - severity: Some("error".to_string()), - message: error.to_string(), - }), + } + dedup_workers(&mut response.items); + if response.items.is_empty() { + let diagnostics = response + .diagnostics + .iter() + .map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message)) + .collect::>() + .join("; "); + let detail = if diagnostics.is_empty() { + "no backend diagnostics".to_string() + } else { + diagnostics + }; + eprintln!( + "Backend returned no runtime workers for workspace {} ({detail}); choose another Workspace", + response.workspace_id + ); + target.clear_workspace(); + continue; } - } - dedup_workers(&mut response.items); - if response.items.is_empty() { - let diagnostics = response - .diagnostics - .iter() - .map(|diagnostic| format!("{}: {}", diagnostic.code, diagnostic.message)) - .collect::>() - .join("; "); - let detail = if diagnostics.is_empty() { - "no backend diagnostics".to_string() - } else { - diagnostics - }; - return Err(Box::new(io::Error::other(format!( - "Backend returned no runtime workers for workspace {} ({detail})", - response.workspace_id - )))); - } - let selected = pick_worker(target.clone(), response.items)?; - let worker = if selected.state == "stopped" { - let restore_target = BackendRuntimeTarget::new( - target.base_url.clone(), - selected.runtime_id.clone(), - selected.worker_id.clone(), - ); - restore_backend_worker(&restore_target) - .await - .map_err(|error| { - io::Error::other(format!( - "failed to restore Backend worker {}/{}: {error}", - selected.runtime_id, selected.worker_id - )) - })? - .result - .worker - .unwrap_or(selected) - } else { - selected - }; - let attach_target = - BackendRuntimeTarget::new(target.base_url, worker.runtime_id, worker.worker_id); - console::run_backend_runtime(attach_target).await + let selected = match pick_worker(target.clone(), response.items)? { + WorkerPickerResult::SwitchWorkspace => { + target.clear_workspace(); + continue; + } + WorkerPickerResult::Selected(selected) => selected, + }; + let worker = if selected.state == "stopped" { + let restore_target = target + .runtime_target(selected.runtime_id.clone(), selected.worker_id.clone()) + .map_err(|error| io::Error::other(error.to_string()))?; + restore_backend_worker(&restore_target) + .await + .map_err(|error| { + io::Error::other(format!( + "failed to restore Backend worker {}/{}: {error}", + selected.runtime_id, selected.worker_id + )) + })? + .result + .worker + .unwrap_or(selected) + } else { + selected + }; + let attach_target = target + .runtime_target(worker.runtime_id, worker.worker_id) + .map_err(|error| io::Error::other(error.to_string()))?; + return console::run_backend_runtime(attach_target).await; + } } fn dedup_workers(workers: &mut Vec) { @@ -92,10 +109,15 @@ fn dedup_workers(workers: &mut Vec) { workers.retain(|worker| seen.insert((worker.runtime_id.clone(), worker.worker_id.clone()))); } +enum WorkerPickerResult { + Selected(BackendWorkerSummary), + SwitchWorkspace, +} + fn pick_worker( target: BackendRuntimeListTarget, mut workers: Vec, -) -> Result> { +) -> Result> { workers.sort_by(|a, b| { a.runtime_id .cmp(&b.runtime_id) @@ -114,7 +136,13 @@ fn pick_worker( Some(Action::Down) => state.next(), Some(Action::Submit) => { close_viewport(&mut terminal)?; - return Ok(state.selected_worker().clone()); + return Ok(WorkerPickerResult::Selected( + state.selected_worker().clone(), + )); + } + Some(Action::SwitchWorkspace) => { + close_viewport(&mut terminal)?; + return Ok(WorkerPickerResult::SwitchWorkspace); } Some(Action::Cancel) => { close_viewport(&mut terminal)?; @@ -181,6 +209,7 @@ enum Action { Up, Down, Submit, + SwitchWorkspace, Cancel, } @@ -197,6 +226,7 @@ fn poll_event() -> io::Result> { KeyCode::Char('k') if !ctrl => Some(Action::Up), KeyCode::Char('j') if !ctrl => Some(Action::Down), KeyCode::Enter => Some(Action::Submit), + KeyCode::Char('w') if !ctrl => Some(Action::SwitchWorkspace), KeyCode::Esc => Some(Action::Cancel), KeyCode::Char('c') if ctrl => Some(Action::Cancel), _ => None, @@ -239,6 +269,8 @@ fn draw(frame: &mut Frame<'_>, state: &BackendWorkerPickerState) { Span::raw(" select "), Span::styled("[enter]", Style::default().fg(Color::Green)), Span::raw(" attach "), + Span::styled("[w]", Style::default().fg(Color::Cyan)), + Span::raw(" switch Workspace "), Span::styled("[esc]", Style::default().fg(Color::Yellow)), Span::raw(" cancel"), ])), diff --git a/crates/tui/src/backend_workspace_picker.rs b/crates/tui/src/backend_workspace_picker.rs new file mode 100644 index 00000000..e64cc71f --- /dev/null +++ b/crates/tui/src/backend_workspace_picker.rs @@ -0,0 +1,240 @@ +use client::{ + BackendWorkspace, BackendWorkspaceCatalogTarget, CreateBackendWorkspaceRepository, + CreateBackendWorkspaceRequest, create_backend_workspace, list_backend_workspaces, +}; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode}; +use ratatui::Terminal; +use ratatui::backend::CrosstermBackend; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Modifier, Style}; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, List, ListItem, ListState, Paragraph}; +use std::error::Error; +use std::io::{self, IsTerminal, Write}; +use std::time::{SystemTime, UNIX_EPOCH}; + +type PickerResult = Result>; + +pub(crate) async fn select_backend_workspace(base_url: &str) -> PickerResult> { + let target = BackendWorkspaceCatalogTarget::new(base_url); + let mut workspaces = Vec::new(); + + 'catalog: loop { + let error = match list_backend_workspaces(&target).await { + Ok(items) => { + workspaces = items; + None + } + Err(fetch_error) => Some(format!("failed to refresh workspaces: {fetch_error}")), + }; + + match pick_workspace(&workspaces, error.as_deref())? { + WorkspacePickerAction::Select(index) => { + return Ok(workspaces.get(index).map(|item| item.workspace_id.clone())); + } + WorkspacePickerAction::Refresh => continue, + WorkspacePickerAction::Create => { + let Some(request) = prompt_create_request()? else { + continue; + }; + loop { + match create_backend_workspace(&target, &request).await { + Ok(response) => return Ok(Some(response.workspace.workspace_id)), + Err(create_error) => { + let creation_error = + format!("workspace creation failed: {create_error}"); + match pick_workspace(&workspaces, Some(&creation_error))? { + WorkspacePickerAction::Select(index) => { + return Ok(workspaces + .get(index) + .map(|item| item.workspace_id.clone())); + } + // Retry the exact request and operation key. + WorkspacePickerAction::Create => continue, + WorkspacePickerAction::Refresh => continue 'catalog, + WorkspacePickerAction::Cancel => return Ok(None), + } + } + } + } + } + WorkspacePickerAction::Cancel => return Ok(None), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum WorkspacePickerAction { + Select(usize), + Create, + Refresh, + Cancel, +} + +fn pick_workspace( + workspaces: &[BackendWorkspace], + error: Option<&str>, +) -> PickerResult { + if !io::stdin().is_terminal() || !io::stdout().is_terminal() { + return Err( + "Backend target has no configured workspace; an interactive terminal is required to choose one" + .into(), + ); + } + let mut terminal = Terminal::new(CrosstermBackend::new(io::stdout()))?; + let mut selected = 0usize; + loop { + terminal.draw(|frame| { + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(3), + Constraint::Length(if error.is_some() { 3 } else { 1 }), + ]) + .split(frame.area()); + frame.render_widget( + Paragraph::new("Choose the Workspace for this Backend session") + .block(Block::default().title("Workspace").borders(Borders::ALL)), + chunks[0], + ); + let rows = workspaces + .iter() + .map(|workspace| { + ListItem::new(Line::from(vec![ + Span::styled( + workspace.display_name.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), + Span::raw(format!(" {} {}", workspace.workspace_id, workspace.state)), + ])) + }) + .collect::>(); + let rows = if rows.is_empty() { + vec![ListItem::new("No accessible Workspaces")] + } else { + rows + }; + let mut state = ListState::default(); + if !workspaces.is_empty() { + state.select(Some(selected)); + } + frame.render_stateful_widget( + List::new(rows) + .block(Block::default().borders(Borders::ALL)) + .highlight_symbol("▶ "), + chunks[1], + &mut state, + ); + let footer = error + .map(|message| { + format!( + "{message} [n] create/retry [r] refresh [Enter] select [Esc] cancel" + ) + }) + .unwrap_or_else(|| { + "[Enter] select [n] new [r] refresh [Esc] cancel".to_string() + }); + frame.render_widget(Paragraph::new(footer), chunks[2]); + })?; + + if let Event::Key(key) = event::read()? + && key.kind == KeyEventKind::Press + { + match key.code { + KeyCode::Up if !workspaces.is_empty() => { + selected = selected.saturating_sub(1); + } + KeyCode::Down if !workspaces.is_empty() => { + selected = (selected + 1).min(workspaces.len() - 1); + } + KeyCode::Enter if !workspaces.is_empty() => { + terminal.clear()?; + return Ok(WorkspacePickerAction::Select(selected)); + } + KeyCode::Char('n') => { + terminal.clear()?; + return Ok(WorkspacePickerAction::Create); + } + KeyCode::Char('r') => { + terminal.clear()?; + return Ok(WorkspacePickerAction::Refresh); + } + KeyCode::Esc | KeyCode::Char('q') => { + terminal.clear()?; + return Ok(WorkspacePickerAction::Cancel); + } + _ => {} + } + } + } +} + +fn prompt_create_request() -> PickerResult> { + disable_raw_mode()?; + let result = prompt_create_request_inner(); + enable_raw_mode()?; + result +} + +fn prompt_create_request_inner() -> PickerResult> { + println!("Create Workspace (leave display name empty to cancel)"); + let display_name = prompt_line("Workspace display name: ")?; + if display_name.is_empty() { + return Ok(None); + } + let uri = prompt_line("Initial repository absolute path/URI: ")?; + if uri.is_empty() { + println!("Repository path/URI is required."); + return Ok(None); + } + let repository_name = prompt_line("Repository display name [Main]: ")?; + let default_ref = prompt_line("Default ref [repository default]: ")?; + let operation_key = format!( + "tui-workspace-create-{}-{}", + std::process::id(), + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + Ok(Some(CreateBackendWorkspaceRequest { + operation_key, + display_name, + repository: CreateBackendWorkspaceRepository { + uri, + display_name: Some(if repository_name.is_empty() { + "Main".to_string() + } else { + repository_name + }), + default_ref: (!default_ref.is_empty()).then_some(default_ref), + }, + })) +} + +fn prompt_line(prompt: &str) -> PickerResult { + print!("{prompt}"); + io::stdout().flush()?; + let mut value = String::new(); + io::stdin().read_line(&mut value)?; + Ok(value.trim().to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn picker_actions_distinguish_switch_refresh_create_and_cancel() { + assert_ne!( + WorkspacePickerAction::Create, + WorkspacePickerAction::Refresh + ); + assert_ne!( + WorkspacePickerAction::Select(0), + WorkspacePickerAction::Cancel + ); + } +} diff --git a/crates/tui/src/lib.rs b/crates/tui/src/lib.rs index 425a7c29..fdc25345 100644 --- a/crates/tui/src/lib.rs +++ b/crates/tui/src/lib.rs @@ -1,5 +1,6 @@ mod app; mod backend_worker_picker; +mod backend_workspace_picker; mod block; mod cache; mod command; From ad729af59226b2697797a48ccfc02a4aeb40dafd Mon Sep 17 00:00:00 2001 From: Hare Date: Thu, 20 Aug 2026 18:34:28 +0900 Subject: [PATCH 15/22] feat: add web workspace chooser and creation --- .../src/lib/workspace/api/http.test.ts | 32 +-- .../lib/workspace/api/workspace-catalog.ts | 164 ++++++++++++++ .../src/lib/workspace/multiplexer.ts | 23 ++ .../workspace/sidebar/GlobalSidebar.svelte | 2 + .../workspace/sidebar/WorkspaceSidebar.svelte | 3 + .../sidebar/WorkspaceSwitcher.svelte | 55 +++++ .../workspace/styles/workspace-catalog.css | 209 ++++++++++++++++++ web/workspace/src/routes/+layout.ts | 28 +-- web/workspace/src/routes/+page.svelte | 193 +++++++++++++++- web/workspace/src/routes/+page.ts | 15 +- .../src/routes/w/[workspaceId]/+error.svelte | 23 ++ .../src/routes/w/[workspaceId]/+layout.svelte | 6 + .../src/routes/w/[workspaceId]/+layout.ts | 22 +- web/workspace/tests/workspace-catalog.test.ts | 107 +++++++++ 14 files changed, 834 insertions(+), 48 deletions(-) create mode 100644 web/workspace/src/lib/workspace/api/workspace-catalog.ts create mode 100644 web/workspace/src/lib/workspace/sidebar/WorkspaceSwitcher.svelte create mode 100644 web/workspace/src/lib/workspace/styles/workspace-catalog.css create mode 100644 web/workspace/src/routes/w/[workspaceId]/+error.svelte create mode 100644 web/workspace/tests/workspace-catalog.test.ts diff --git a/web/workspace/src/lib/workspace/api/http.test.ts b/web/workspace/src/lib/workspace/api/http.test.ts index d4e9db62..93521aa6 100644 --- a/web/workspace/src/lib/workspace/api/http.test.ts +++ b/web/workspace/src/lib/workspace/api/http.test.ts @@ -38,25 +38,31 @@ Deno.test("workspace route helpers scope browser routes and API by immutable wor ); }); -Deno.test("root layout bootstraps only the scoped workspace entry", async () => { +Deno.test("root layout leaves Workspace selection explicit", async () => { const layout = await Deno.readTextFile( new URL("./../../../routes/+layout.ts", import.meta.url), ); assert( - layout.includes('loadJson(fetch, "/api/workspace")'), - "unscoped layout may use only the workspace-id bootstrap endpoint", + !layout.includes("/api/workspace") && + !layout.includes("redirect(") && + layout.includes("Workspace selection is explicit"), + "root layout must not infer or redirect to a singleton Workspace", ); +}); + +Deno.test("Workspace route changes dispose old multiplexed subscription state", async () => { + const [layout, multiplexer] = await Promise.all([ + Deno.readTextFile( + new URL("./../../../routes/w/[workspaceId]/+layout.svelte", import.meta.url), + ), + Deno.readTextFile(new URL("./../multiplexer.ts", import.meta.url)), + ]); assert( - layout.includes("throw redirect(307") && - layout.includes("workspaceRoute(workspace.data.workspace_id)") && - !layout.includes("scopedCompatibilityRoute") && - !layout.includes("workspaceRoute(workspaceId, pathname)"), - "root layout should redirect only to the scoped workspace entry", - ); - assert( - !layout.includes("`/api${path}`") && - !layout.includes('"/api/repositories"'), - "layout must not fall back to unscoped workspace-scoped API calls", + layout.includes("disposeWorkspaceMultiplexer(workspaceId)") && + multiplexer.includes("multiplexers.delete(workspaceId)") && + multiplexer.includes("this.#subscriptions.clear()") && + multiplexer.includes("this.#socket?.close()"), + "changing Workspace must dispose old subscriptions and transport state", ); }); diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts new file mode 100644 index 00000000..44f70d1e --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -0,0 +1,164 @@ +export type WorkspaceCatalogRecord = { + workspace_id: string; + owner_account_id: string | null; + display_name: string; + state: string; + created_at: string; + updated_at: string; +}; + +export type WorkspaceRepositoryRecord = { + workspace_id: string; + repository_id: string; + name: string; + kind: string; + uri: string; + default_ref: string | null; +}; + +export type WorkspaceCatalogItem = WorkspaceCatalogRecord & { + repositories: WorkspaceRepositoryRecord[]; + repository_error?: string; +}; + +export type CreateWorkspaceRequest = { + operation_key: string; + display_name: string; + repository: { + uri: string; + display_name: string | null; + default_ref: string | null; + }; +}; + +export type CreateWorkspaceResponse = { + workspace: WorkspaceCatalogRecord; + repository: WorkspaceRepositoryRecord; + config_revision: number; + request_fingerprint: string; + replayed: boolean; +}; + +export class WorkspaceCatalogError extends Error { + constructor( + public readonly status: number | null, + message: string, + ) { + super(message); + this.name = "WorkspaceCatalogError"; + } +} + +type Fetch = typeof globalThis.fetch; + +export async function listWorkspaces( + fetcher: Fetch, +): Promise { + return await fetchJson( + fetcher, + "/api/workspaces?limit=200", + ); +} + +export async function listWorkspaceRepositories( + fetcher: Fetch, + workspaceId: string, +): Promise { + return await fetchJson( + fetcher, + `/api/w/${encodeURIComponent(workspaceId)}/repositories`, + ); +} + +export async function loadWorkspaceCatalog( + fetcher: Fetch, +): Promise { + const workspaces = await listWorkspaces(fetcher); + return await Promise.all( + workspaces.map(async (workspace) => { + try { + return { + ...workspace, + repositories: await listWorkspaceRepositories( + fetcher, + workspace.workspace_id, + ), + }; + } catch (error) { + return { + ...workspace, + repositories: [], + repository_error: errorMessage(error), + }; + } + }), + ); +} + +export async function createWorkspace( + fetcher: Fetch, + request: CreateWorkspaceRequest, +): Promise { + return await fetchJson(fetcher, "/api/workspaces", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }); +} + +export function creationErrorMessage(error: unknown): string { + if (!(error instanceof WorkspaceCatalogError)) { + return `Network error. The same operation can be retried safely. ${ + errorMessage(error) + }`; + } + switch (error.status) { + case 400: + return `Validation failed. ${error.message}`; + case 401: + case 403: + return `You are not authorized to create this Workspace. ${error.message}`; + case 409: + return `Creation conflicts with current Backend state. ${error.message}`; + default: + return `Workspace creation failed. The same operation can be retried safely. ${error.message}`; + } +} + +export function createOperationKey(): string { + if (typeof crypto !== "undefined" && "randomUUID" in crypto) { + return `web-workspace-create-${crypto.randomUUID()}`; + } + return `web-workspace-create-${Date.now()}-${ + Math.random().toString(16).slice(2) + }`; +} + +async function fetchJson( + fetcher: Fetch, + input: string, + init?: RequestInit, +): Promise { + let response: Response; + try { + response = await fetcher(input, init); + } catch (error) { + throw new WorkspaceCatalogError(null, errorMessage(error)); + } + if (!response.ok) { + let detail = `${response.status} ${response.statusText}`.trim(); + try { + const body = await response.json(); + if (typeof body?.message === "string") detail = body.message; + else if (typeof body?.error === "string") detail = body.error; + } catch { + // Preserve the bounded status text when the Backend did not return JSON. + } + throw new WorkspaceCatalogError(response.status, detail); + } + return await response.json() as T; +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/web/workspace/src/lib/workspace/multiplexer.ts b/web/workspace/src/lib/workspace/multiplexer.ts index e155a1df..5436e161 100644 --- a/web/workspace/src/lib/workspace/multiplexer.ts +++ b/web/workspace/src/lib/workspace/multiplexer.ts @@ -42,6 +42,13 @@ export function workspaceMultiplexer(workspaceId: string): WorkspaceMultiplexer return multiplexer; } +export function disposeWorkspaceMultiplexer(workspaceId: string): void { + const multiplexer = multiplexers.get(workspaceId); + if (!multiplexer) return; + multiplexers.delete(workspaceId); + multiplexer.dispose(); +} + export class WorkspaceMultiplexer { readonly #workspaceId: string; readonly #subscriptions = new Map(); @@ -219,6 +226,22 @@ export class WorkspaceMultiplexer { } } + dispose(): void { + this.#closed = true; + if (this.#reconnectTimer) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = null; + } + for (const subscription of this.#subscriptions.values()) { + subscription.listener.onStatus?.('closed', 'Workspace selection changed'); + } + this.#subscriptions.clear(); + this.#requests.clear(); + this.#runtimeSubscriptions.clear(); + this.#socket?.close(); + this.#socket = null; + } + #send(frame: SubscriptionFrame): void { if (this.#socket?.readyState !== WebSocket.OPEN) return; this.#socket.send(JSON.stringify(frame)); diff --git a/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte index 9130b1a1..06a36ae2 100644 --- a/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/GlobalSidebar.svelte @@ -8,6 +8,8 @@ const { currentPath }: Props = $props(); const items = [ + { href: '/', label: 'Workspaces' }, + { href: '/#workspace-create-title', label: 'Create Workspace' }, { href: '/account', label: 'Account' }, { href: '/login/device', label: 'Device Login' }, ]; diff --git a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte index 6d18e81d..a83390eb 100644 --- a/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte +++ b/web/workspace/src/lib/workspace/sidebar/WorkspaceSidebar.svelte @@ -6,6 +6,7 @@ import RepositoriesNavSection from './RepositoriesNavSection.svelte'; import TicketsNavSection from './TicketsNavSection.svelte'; import WorkersNavSection from './WorkersNavSection.svelte'; + import WorkspaceSwitcher from './WorkspaceSwitcher.svelte'; import type { RepositoryListResponse, WorkspaceResponse } from './types'; type Props = { @@ -76,6 +77,8 @@ + {#if workspaceId}{/if} +