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/protocol/src/lib.rs b/crates/protocol/src/lib.rs index 0a62b692..0d2ae8e6 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 @@ -547,6 +556,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 +729,79 @@ 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 started_at_ms: u64, + pub observed_at_ms: u64, + pub last_output_at_ms: Option, + 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, + observed_at_ms: u64, + }, + Output { + command_id: String, + stream: CommandStream, + start_offset: u64, + end_offset: u64, + content: String, + observed_at_ms: u64, + }, + Terminal { + command_id: String, + status: CommandStatus, + exit_code: Option, + stdout_end_offset: u64, + stderr_end_offset: u64, + observed_at_ms: u64, + }, +} + +/// 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 +812,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 +1463,22 @@ mod tests { state: InFlightToolCallState::StreamingArgs, }, ], + commands: vec![CommandSnapshot { + command_id: "command-1".into(), + tool_call_id: Some("call_1".into()), + status: CommandStatus::Running, + started_at_ms: 100, + observed_at_ms: 120, + last_output_at_ms: Some(120), + 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 +1548,41 @@ 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(), + observed_at_ms: 42, + }, + }; + 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_eq!(parsed["data"]["event"]["observed_at_ms"], 42); + 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, + observed_at_ms: 42, + } + } 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":[]}}}"#; @@ -1802,6 +1941,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/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/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/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/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 fd632bb9..1916605a 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,10 +1321,16 @@ 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); } + // 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 @@ -2002,6 +2011,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 +2039,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 +2067,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], @@ -3474,6 +3510,7 @@ mod completion_flow_tests { finished: false, }, ], + commands: Vec::new(), }, internal_workers: Vec::new(), }); @@ -3546,6 +3583,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/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; 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..71b67952 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::time::Duration; +use std::sync::{Arc, Mutex as StdMutex}; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; 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,172 @@ 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; + +fn command_observed_at_ms() -> u64 { + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() + .try_into() + .unwrap_or(u64::MAX) +} + #[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) { + let observed_at_ms = command_observed_at_ms(); + 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, + started_at_ms: observed_at_ms, + observed_at_ms, + last_output_at_ms: None, + 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, + observed_at_ms, + }); + } + + 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(); + let observed_at_ms = command_observed_at_ms(); + if let Some(snapshot) = self + .inner + .snapshots + .lock() + .expect("command telemetry mutex poisoned") + .get_mut(command_id) + { + snapshot.observed_at_ms = observed_at_ms; + snapshot.last_output_at_ms = Some(observed_at_ms); + 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, + observed_at_ms, + }); + } + + fn terminal(&self, command_id: &str, status: CommandStatus, exit_code: Option) { + let observed_at_ms = command_observed_at_ms(); + let (stdout_end_offset, stderr_end_offset) = 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; + snapshot.observed_at_ms = observed_at_ms; + (snapshot.stdout.end_offset, snapshot.stderr.end_offset) + } else { + (0, 0) + }; + let _ = self.inner.events.send(CommandEvent::Terminal { + command_id: command_id.to_string(), + status, + exit_code, + stdout_end_offset, + stderr_end_offset, + observed_at_ms, + }); + } + + 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 +227,7 @@ struct LocalWorkdirSessionInner { close_lock: Mutex<()>, next_command_id: AtomicU64, commands: Mutex>, + command_telemetry: CommandTelemetry, } impl Drop for LocalWorkdirSessionInner { @@ -171,6 +330,7 @@ impl LocalWorkdirSession { close_lock: Mutex::new(()), next_command_id: AtomicU64::new(1), commands: Mutex::new(HashMap::new()), + command_telemetry: CommandTelemetry::new(), }), } } @@ -502,23 +662,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 +702,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 +762,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 +897,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 +913,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_decoder = CommandOutputDecoder::default(); + let mut stderr_decoder = CommandOutputDecoder::default(); + 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_decoder, + &telemetry, + &command_id, + CommandStream::Stdout, + &stdout_path, + false, + )?; + publish_available_output( + &mut stderr_reader, + &mut stderr_decoder, + &telemetry, + &command_id, + CommandStream::Stderr, + &stderr_path, + false, + )?; + } + } + }; + + publish_available_output( + &mut stdout_reader, + &mut stdout_decoder, + &telemetry, + &command_id, + CommandStream::Stdout, + &stdout_path, + true, + )?; + publish_available_output( + &mut stderr_reader, + &mut stderr_decoder, + &telemetry, + &command_id, + CommandStream::Stderr, + &stderr_path, + true, + )?; + 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, }) } +#[derive(Debug, Default)] +struct CommandOutputDecoder { + read_offset: u64, + emitted_offset: u64, + pending: Vec, +} + +fn publish_available_output( + file: &mut std::fs::File, + decoder: &mut CommandOutputDecoder, + telemetry: &CommandTelemetry, + command_id: &str, + stream: CommandStream, + path: &Path, + flush: bool, +) -> Result<(), WorkdirError> { + file.seek(SeekFrom::Start(decoder.read_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 { + publish_decoded_output(decoder, telemetry, command_id, stream, flush); + return Ok(()); + } + decoder.pending.extend_from_slice(&buffer[..read]); + decoder.read_offset = decoder.read_offset.saturating_add(read as u64); + publish_decoded_output(decoder, telemetry, command_id, stream, false); + if read < COMMAND_EVENT_CHUNK_BYTES { + if flush { + publish_decoded_output(decoder, telemetry, command_id, stream, true); + } + return Ok(()); + } + } +} + +fn publish_decoded_output( + decoder: &mut CommandOutputDecoder, + telemetry: &CommandTelemetry, + command_id: &str, + stream: CommandStream, + flush: bool, +) { + let prefix_len = if flush { + decoder.pending.len() + } else { + stable_utf8_prefix_len(&decoder.pending) + }; + if prefix_len == 0 { + return; + } + telemetry.output( + command_id, + stream, + decoder.emitted_offset, + &decoder.pending[..prefix_len], + ); + decoder.emitted_offset = decoder.emitted_offset.saturating_add(prefix_len as u64); + decoder.pending.drain(..prefix_len); +} + +/// Return the byte prefix that can be decoded now without replacing a valid +/// UTF-8 scalar whose remaining bytes may arrive in a later file read. Definite +/// invalid sequences remain in the prefix and are rendered lossily, preserving +/// the existing arbitrary-byte output behavior. +fn stable_utf8_prefix_len(bytes: &[u8]) -> usize { + let mut inspected = 0; + while inspected < bytes.len() { + match std::str::from_utf8(&bytes[inspected..]) { + Ok(_) => return bytes.len(), + Err(error) => { + inspected += error.valid_up_to(); + match error.error_len() { + Some(invalid_len) => inspected += invalid_len, + None => return inspected, + } + } + } + } + inspected +} + fn read_command_output_files( stdout_path: &Path, stderr_path: &Path, @@ -1024,6 +1391,7 @@ mod tests { command: "sleep 30".to_owned(), timeout_secs: 60, output_limit: 1024, + tool_call_id: None, }, ) .await @@ -1549,6 +1917,7 @@ mod tests { command: "pwd && printf provider-command".into(), timeout_secs: 5, output_limit: 4096, + tool_call_id: None, }, ) .await @@ -1583,6 +1952,7 @@ mod tests { command: "printf 'aéz'".into(), timeout_secs: 5, output_limit: 1024, + tool_call_id: None, }, ) .await @@ -1620,6 +1990,213 @@ mod tests { )); } + #[test] + fn command_output_decoder_preserves_utf8_split_across_file_reads() { + let dir = TempDir::new().unwrap(); + let path = dir.path().join("command.out"); + let mut first_write = vec![b'a'; COMMAND_EVENT_CHUNK_BYTES - 1]; + first_write.push(0xe2); + std::fs::write(&path, first_write).unwrap(); + + let telemetry = CommandTelemetry::new(); + let mut events = telemetry.subscribe(); + telemetry.started("command-utf8", None); + let mut decoder = CommandOutputDecoder::default(); + let mut reader = std::fs::File::open(&path).unwrap(); + publish_available_output( + &mut reader, + &mut decoder, + &telemetry, + "command-utf8", + CommandStream::Stdout, + &path, + false, + ) + .unwrap(); + assert_eq!(decoder.pending, vec![0xe2]); + + let mut writer = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + writer.write_all(&[0x82, 0xac]).unwrap(); + writer.flush().unwrap(); + publish_available_output( + &mut reader, + &mut decoder, + &telemetry, + "command-utf8", + CommandStream::Stdout, + &path, + false, + ) + .unwrap(); + + let output = std::iter::from_fn(|| events.try_recv().ok()) + .filter_map(|event| match event { + CommandEvent::Output { + stream: CommandStream::Stdout, + content, + .. + } => Some(content), + _ => None, + }) + .collect::(); + assert_eq!(output.len(), COMMAND_EVENT_CHUNK_BYTES - 1 + "€".len()); + assert!(output.ends_with('€')); + assert!(!output.contains('\u{fffd}')); + assert!(decoder.pending.is_empty()); + } + + #[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 stdout_chunks = 0; + 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_chunks += 1; + stdout.push_str(&content); + } + CommandStream::Stderr => stderr.push_str(&content), + } + } + CommandEvent::Terminal { + command_id, + status, + exit_code, + stdout_end_offset, + stderr_end_offset, + observed_at_ms, + } => { + assert_eq!(command_id, handle.0); + terminal = Some(( + status, + exit_code, + stdout_end_offset, + stderr_end_offset, + observed_at_ms, + )); + } + } + } + let (status, exit_code, stdout_end_offset, stderr_end_offset, observed_at_ms) = + terminal.unwrap(); + assert_eq!(status, CommandStatus::Completed); + assert_eq!(exit_code, Some(0)); + assert_eq!(stdout_end_offset, "readydone".len() as u64); + assert_eq!(stderr_end_offset, "warning".len() as u64); + assert!(observed_at_ms > 0); + assert!( + stdout_chunks >= 2, + "long-running output should stream incrementally" + ); + 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 +2207,7 @@ mod tests { command: "sleep 30".into(), timeout_secs: 60, output_limit: 1024, + tool_call_id: None, }, ) .await @@ -1658,12 +2236,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..47527ad8 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,63 @@ 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 started_at_ms: u64, + pub observed_at_ms: u64, + pub last_output_at_ms: Option, + 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, + observed_at_ms: u64, + }, + Output { + command_id: String, + stream: CommandStream, + start_offset: u64, + end_offset: u64, + content: String, + observed_at_ms: u64, + }, + Terminal { + command_id: String, + status: CommandStatus, + exit_code: Option, + stdout_end_offset: u64, + stderr_end_offset: u64, + observed_at_ms: u64, + }, } #[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..80f5d5dd 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,125 @@ impl WorkerController { } } +pub(crate) fn wire_workdir_command_events( + session: &Arc, + in_flight: &InFlightEvents, +) { + in_flight.replace_command_snapshot(protocol_command_snapshots(session.as_ref())); + let Some(mut events) = session.subscribe_command_events() else { + return; + }; + // Keep only a weak reference in the observer task. Holding the session + // strongly here would keep its broadcast sender alive forever and prevent + // the receiver from observing closure during Worker teardown. + let session = Arc::downgrade(session); + 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(_)) => { + let Some(session) = session.upgrade() else { + break; + }; + in_flight + .replace_command_snapshot(protocol_command_snapshots(session.as_ref())); + } + Err(broadcast::error::RecvError::Closed) => break, + } + } + }); +} + +fn protocol_command_snapshots(session: &dyn WorkdirSession) -> Vec { + session + .command_snapshot() + .into_iter() + .map(protocol_command_snapshot) + .collect() +} + +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), + started_at_ms: snapshot.started_at_ms, + observed_at_ms: snapshot.observed_at_ms, + last_output_at_ms: snapshot.last_output_at_ms, + 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, + observed_at_ms, + } => ProtocolCommandEvent::Started { + command_id, + tool_call_id, + observed_at_ms, + }, + WorkdirCommandEvent::Output { + command_id, + stream, + start_offset, + end_offset, + content, + observed_at_ms, + } => ProtocolCommandEvent::Output { + command_id, + stream: match stream { + WorkdirCommandStream::Stdout => ProtocolCommandStream::Stdout, + WorkdirCommandStream::Stderr => ProtocolCommandStream::Stderr, + }, + start_offset, + end_offset, + content, + observed_at_ms, + }, + WorkdirCommandEvent::Terminal { + command_id, + status, + exit_code, + stdout_end_offset, + stderr_end_offset, + observed_at_ms, + } => ProtocolCommandEvent::Terminal { + command_id, + status: protocol_command_status(status), + exit_code, + stdout_end_offset, + stderr_end_offset, + observed_at_ms, + }, + } +} + +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/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/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, }) diff --git a/crates/worker/src/in_flight.rs b/crates/worker/src/in_flight.rs index 6a08d881..ecf56f75 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,92 @@ 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, + observed_at_ms, + } => { + 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, + started_at_ms: *observed_at_ms, + observed_at_ms: *observed_at_ms, + last_output_at_ms: None, + stdout: CommandStreamSlice::default(), + stderr: CommandStreamSlice::default(), + exit_code: None, + }); + } + CommandEvent::Output { + command_id, + stream, + start_offset, + end_offset, + content, + observed_at_ms, + } => { + 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, + started_at_ms: *observed_at_ms, + observed_at_ms: *observed_at_ms, + last_output_at_ms: Some(*observed_at_ms), + stdout: CommandStreamSlice::default(), + stderr: CommandStreamSlice::default(), + exit_code: None, + }); + self.commands.last_mut().expect("command was inserted") + } + }; + command.observed_at_ms = *observed_at_ms; + command.last_output_at_ms = Some(*observed_at_ms); + 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 +375,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 +687,57 @@ 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()), + observed_at_ms: 100, + }); + in_flight.publish_command_event(CommandEvent::Output { + command_id: "command-1".into(), + stream: CommandStream::Stdout, + start_offset: 0, + end_offset: 5, + content: "ready".into(), + observed_at_ms: 110, + }); + + 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, + stdout_end_offset: 5, + stderr_end_offset: 0, + observed_at_ms: 200, + }); + 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..d9239cfd 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; @@ -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, @@ -555,6 +576,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()); @@ -582,6 +606,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 +916,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 +944,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/crates/worker/tests/controller_test.rs b/crates/worker/tests/controller_test.rs index bbe43ba5..3cd74abc 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,180 @@ 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_refreshes_command_snapshot_after_high_output_provider_lag() { + 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-lag-recovery-workdir"), + pwd.clone(), + pwd, + worker.scope().clone(), + WorkdirSessionCapabilities::ALL, + )); + worker.bind_workdir_session(Some(Arc::clone(&session))); + let handle = spawn_controller(worker).await; + + // Local command telemetry uses 8 KiB chunks and a 256-event channel. One + // synchronous file-poll burst with 300 chunks deterministically makes the + // worker-side receiver observe `Lagged` before this command terminates. + let command = session + .start_command(CommandRequest { + command: "dd if=/dev/zero bs=8192 count=300 2>/dev/null | tr '\\0' x; sleep 5" + .to_owned(), + timeout_secs: 10, + output_limit: 1024, + tool_call_id: Some("tool-high-output".into()), + }) + .await + .unwrap(); + + let expected_end_offset = 300_u64 * 8192; + let deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(3); + let recovered = loop { + let Event::Snapshot { in_flight, .. } = handle.snapshot_event() else { + panic!("worker snapshot expected"); + }; + if let Some(snapshot) = in_flight + .commands + .iter() + .find(|snapshot| snapshot.command_id == command.0) + && snapshot.stdout.end_offset >= expected_end_offset + { + break snapshot.clone(); + } + assert!( + tokio::time::Instant::now() < deadline, + "timed out waiting for lag recovery snapshot" + ); + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + }; + + assert_eq!(recovered.tool_call_id.as_deref(), Some("tool-high-output")); + assert_eq!(recovered.status, protocol::CommandStatus::Running); + assert!(recovered.stdout.truncated); + assert!(recovered.stdout.start_offset > 0); + assert_eq!(recovered.stdout.end_offset, expected_end_offset); + assert!(recovered.stdout.content.len() <= 32 * 1024); + assert!(recovered.stdout.content.bytes().all(|byte| byte == b'x')); + + session.cancel_command(command.clone()).await.unwrap(); + let output = session + .command_output(CommandOutputRequest { + handle: command, + cursor: 0, + limit: 1024, + wait: true, + }) + .await + .unwrap(); + assert_eq!(output.status, workdir::CommandStatus::Cancelled); + 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 +454,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/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/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/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/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 ca162ab3..3a20f09d 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; @@ -273,6 +339,21 @@ pub struct WorkspaceApi { worker_control_locks: Arc>>>>, } +#[derive(Clone)] +struct ServerAuthApi { + config: ServerConfig, + store: Arc, +} + +impl From<&WorkspaceApi> for ServerAuthApi { + fn from(api: &WorkspaceApi) -> Self { + Self { + config: api.config.clone(), + store: api.store.clone(), + } + } +} + #[derive(Clone)] struct WorkspaceWorkerRemoveExecutor { workspace_id: String, @@ -682,6 +763,233 @@ 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, 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 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), + }; + 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.is_empty() && is_server_static_forward(path) { + return serve_server_static_shell(&api, path).await; + } + 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("/_app/") + || path.starts_with("/assets/") +} + +fn is_server_static_forward(path: &str) -> bool { + !path.starts_with("/api/") && !path.starts_with("/internal/") +} + +async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Response { + let Some(static_root) = api.template.static_assets_dir.as_ref() else { + return StatusCode::NOT_FOUND.into_response(); + }; + static_file_or_spa_response(static_root, path).await +} + +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)) + | (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), + _ => None, + } +} + +pub async fn build_workspace_server_router( + template: ServerConfig, + store: Arc, +) -> Result { + let auth = build_server_auth_router(ServerAuthApi { + config: template.clone(), + store: store.clone(), + }); + let api = WorkspaceServerApi::new(template, store); + api.preload().await?; + let catalog = Router::new() + .route( + "/api/workspaces", + get(list_server_workspaces).post(create_server_workspace), + ) + .fallback(dispatch_workspace_request) + .with_state(api); + Ok(auth.merge(catalog)) +} + impl WorkspaceApi { pub fn with_config_schema_provider( mut self, @@ -1264,7 +1572,7 @@ fn resolve_backend_path(workspace_root: &Path, path: &Path) -> PathBuf { } } -pub fn build_router(api: WorkspaceApi) -> Router { +fn build_server_auth_router(api: ServerAuthApi) -> Router { Router::new() .route("/api/auth/config", get(get_auth_config)) .route("/api/auth/bootstrap-user", post(post_auth_bootstrap_user)) @@ -1285,10 +1593,22 @@ pub fn build_router(api: WorkspaceApi) -> Router { post(post_passkey_login_complete), ) .route("/api/auth/logout", post(post_auth_logout)) - .route("/api/auth/device-login/start", post(post_device_login_start)) - .route("/api/auth/device-login/approve", post(post_device_login_approve)) + .route( + "/api/auth/device-login/start", + post(post_device_login_start), + ) + .route( + "/api/auth/device-login/approve", + post(post_device_login_approve), + ) .route("/api/auth/device-login/poll", post(post_device_login_poll)) .route("/api/auth/whoami", get(get_auth_whoami)) + .with_state(api) +} + +pub fn build_router(api: WorkspaceApi) -> Router { + let auth = build_server_auth_router(ServerAuthApi::from(&api)); + let workspace = Router::new() .route("/api/workspace", get(get_workspace)) .route("/api/w/{workspace_id}/workspace", get(scoped_get_workspace)) .route( @@ -1687,8 +2007,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( @@ -1812,7 +2132,8 @@ pub fn build_router(api: WorkspaceApi) -> Router { get(scoped_list_host_workers), ) .fallback(get(static_or_spa_fallback)) - .with_state(api) + .with_state(api); + auth.merge(workspace) .layer(middleware::from_fn(log_failed_api_response)) } @@ -1872,6 +2193,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, @@ -4238,7 +4569,7 @@ async fn scoped_repair_merge_request_selector( let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; require_workspace_access(&workspace_id, &api)?; reject_non_browser_reopen_auth(&headers)?; - let _actor = require_actor(&api, &headers).await?; + let _actor = require_actor(&ServerAuthApi::from(&api), &headers).await?; if !input.explicit_confirmation { return Err(Error::BrowserReopenConfirmationRequired.into()); } @@ -8014,12 +8345,12 @@ struct LogoutResponse { status: String, } -async fn get_auth_config(State(api): State) -> ApiResult> { +async fn get_auth_config(State(api): State) -> ApiResult> { Ok(Json(auth_public_config(&api.config))) } async fn post_auth_bootstrap_user( - State(api): State, + State(api): State, Json(request): Json, ) -> ApiResult> { let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; @@ -8027,7 +8358,7 @@ async fn post_auth_bootstrap_user( } async fn post_passkey_registration_options( - State(api): State, + State(api): State, headers: HeaderMap, Json(request): Json, ) -> ApiResult> { @@ -8076,7 +8407,7 @@ async fn post_passkey_registration_options( } async fn post_passkey_registration_complete( - State(api): State, + State(api): State, Json(request): Json, ) -> ApiResult { let challenge = api @@ -8145,7 +8476,7 @@ async fn post_passkey_registration_complete( } async fn post_passkey_login_options( - State(api): State, + State(api): State, headers: HeaderMap, Json(request): Json, ) -> ApiResult> { @@ -8196,7 +8527,7 @@ async fn post_passkey_login_options( } async fn post_passkey_login_complete( - State(api): State, + State(api): State, Json(request): Json, ) -> ApiResult { let challenge = api @@ -8279,7 +8610,7 @@ async fn post_passkey_login_complete( issue_browser_session_response(&api, user) } -fn issue_browser_session_response(api: &WorkspaceApi, user: UserRecord) -> ApiResult { +fn issue_browser_session_response(api: &ServerAuthApi, user: UserRecord) -> ApiResult { let session_token = mint_secret("yoi_sess"); api.store.create_browser_session(&BrowserSessionRecord { session_id: new_id("session"), @@ -8312,7 +8643,7 @@ fn issue_browser_session_response(api: &WorkspaceApi, user: UserRecord) -> ApiRe } async fn post_device_login_start( - State(api): State, + State(api): State, Json(request): Json, ) -> ApiResult> { let auth = auth_public_config(&api.config); @@ -8347,7 +8678,7 @@ async fn post_device_login_start( } async fn post_device_login_approve( - State(api): State, + State(api): State, headers: HeaderMap, Json(request): Json, ) -> ApiResult> { @@ -8407,7 +8738,7 @@ async fn post_device_login_approve( } async fn post_device_login_poll( - State(api): State, + State(api): State, Json(request): Json, ) -> ApiResult> { let Some(flow) = api @@ -8448,7 +8779,7 @@ async fn post_device_login_poll( } async fn get_auth_whoami( - State(api): State, + State(api): State, headers: HeaderMap, ) -> ApiResult> { Ok(Json(WhoamiResponse { @@ -8457,7 +8788,7 @@ async fn get_auth_whoami( } async fn post_auth_logout( - State(api): State, + State(api): State, headers: HeaderMap, ) -> ApiResult { let auth = auth_public_config(&api.config); @@ -8546,7 +8877,7 @@ fn webauthn_for_auth(auth: &AuthPublicConfig) -> ApiResult { .map_err(|error| auth_error("webauthn_builder_failed", &error.to_string()).into()) } -fn passkeys_for_user(api: &WorkspaceApi, user_id: &str) -> ApiResult> { +fn passkeys_for_user(api: &ServerAuthApi, user_id: &str) -> ApiResult> { api.store .list_passkey_credentials_for_user(user_id)? .into_iter() @@ -8588,7 +8919,7 @@ fn auth_public_config(config: &ServerConfig) -> AuthPublicConfig { } fn ensure_user_account( - api: &WorkspaceApi, + api: &ServerAuthApi, handle: &str, display_name: Option<&str>, ) -> ApiResult { @@ -8632,12 +8963,15 @@ fn user_response(user: UserRecord) -> AuthenticatedUser { } } -async fn resolve_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult> { +async fn resolve_actor( + api: &ServerAuthApi, + headers: &HeaderMap, +) -> ApiResult> { let cookie_name = auth_public_config(&api.config).cookie_name; Ok(resolve_request_actor(api.store.as_ref(), headers, &cookie_name).await?) } -async fn require_actor(api: &WorkspaceApi, headers: &HeaderMap) -> ApiResult { +async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult { resolve_actor(api, headers).await?.ok_or_else(|| { auth_error( "auth_required", @@ -9501,13 +9835,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) @@ -12207,16 +12548,7 @@ async fn static_or_spa_fallback(State(api): State, uri: Uri) -> Re return StatusCode::NOT_FOUND.into_response(); }; - match read_static_or_index(static_root, uri.path()).await { - Ok(StaticAsset { - bytes, - content_type, - }) => (StatusCode::OK, [(CONTENT_TYPE, content_type)], bytes).into_response(), - Err(error) => { - tracing::debug!(%error, path = %uri.path(), "failed to serve static asset"); - StatusCode::NOT_FOUND.into_response() - } - } + static_file_or_spa_response(static_root, scoped_workspace_static_path(uri.path())).await } fn unscoped_workspace_ui_redirect( @@ -12224,9 +12556,7 @@ fn unscoped_workspace_ui_redirect( query: Option<&str>, workspace_id: &str, ) -> Option { - let scoped_tail = if path == "/" { - "" - } else if ["/repositories", "/objectives", "/settings", "/runtimes"] + let scoped_tail = if ["/repositories", "/objectives", "/settings", "/runtimes"] .iter() .any(|prefix| path == *prefix || path.starts_with(&format!("{prefix}/"))) { @@ -12258,6 +12588,29 @@ struct StaticAsset { content_type: &'static str, } +async fn static_file_or_spa_response(static_root: &Path, request_path: &str) -> Response { + match read_static_or_index(static_root, request_path).await { + Ok(StaticAsset { + bytes, + content_type, + }) => (StatusCode::OK, [(CONTENT_TYPE, content_type)], bytes).into_response(), + Err(error) => { + tracing::debug!(%error, path = request_path, "failed to serve static asset"); + StatusCode::NOT_FOUND.into_response() + } + } +} + +fn scoped_workspace_static_path(path: &str) -> &str { + let Some(scoped) = path.strip_prefix("/w/") else { + return path; + }; + match scoped.find('/') { + Some(index) => &scoped[index..], + None => "/", + } +} + async fn read_static_or_index(root: &Path, request_path: &str) -> Result { let candidate = safe_static_candidate(root, request_path)?; let file = if tokio::fs::metadata(&candidate) @@ -12884,38 +13237,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() ); @@ -14153,6 +14478,333 @@ mod tests { config } + #[tokio::test] + async fn server_router_serves_workspace_chooser_before_first_workspace_exists() { + let dir = tempfile::tempdir().unwrap(); + let static_dir = dir.path().join("static"); + std::fs::create_dir_all(static_dir.join("_app/immutable/entry")).unwrap(); + std::fs::write( + static_dir.join("index.html"), + "
Workspace chooser
", + ) + .unwrap(); + std::fs::write( + static_dir.join("_app/immutable/entry/start.js"), + "console.log('workspace app');", + ) + .unwrap(); + let mut template = test_server_config(dir.path()); + template.static_assets_dir = Some(static_dir); + let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); + let app = build_workspace_server_router(template, store) + .await + .unwrap(); + + for (uri, expected) in [ + ("/", "
Workspace chooser
"), + ("/account", "
Workspace chooser
"), + ("/login/device", "
Workspace chooser
"), + ( + "/_app/immutable/entry/start.js", + "console.log('workspace app');", + ), + ] { + let response = app + .clone() + .oneshot(Request::builder().uri(uri).body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(), + ) + .unwrap(), + expected + ); + } + + let response = app + .clone() + .oneshot( + Request::builder() + .uri("/api/workspaces") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(serde_json::from_slice::(&body).unwrap(), json!([])); + + let auth = app + .oneshot( + Request::builder() + .uri("/api/auth/config") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(auth.status(), StatusCode::OK); + let body = to_bytes(auth.into_body(), usize::MAX).await.unwrap(); + let auth: Value = serde_json::from_slice(&body).unwrap(); + assert!(auth["rp_id"].is_string()); + assert!(auth["cookie_name"].is_string()); + } + + #[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 static_dir = dir.path().join("static"); + std::fs::create_dir_all(static_dir.join("_app/immutable/entry")).unwrap(); + std::fs::write( + static_dir.join("index.html"), + "
Workspace chooser
", + ) + .unwrap(); + std::fs::write( + static_dir.join("_app/immutable/entry/start.js"), + "console.log('workspace app');", + ) + .unwrap(); + let mut template = test_server_config(dir.path()); + template.static_assets_dir = Some(static_dir); + 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 chooser = app + .clone() + .oneshot(Request::builder().uri("/").body(Body::empty()).unwrap()) + .await + .unwrap(); + assert_eq!(chooser.status(), StatusCode::OK); + assert_eq!( + String::from_utf8( + to_bytes(chooser.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(), + ) + .unwrap(), + "
Workspace chooser
" + ); + + for asset_uri in [ + "/_app/immutable/entry/start.js".to_string(), + format!( + "/w/{}/_app/immutable/entry/start.js", + workspace_a.workspace.workspace_id + ), + ] { + let response = app + .clone() + .oneshot( + Request::builder() + .uri(asset_uri) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + assert_eq!( + String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .unwrap() + .to_vec(), + ) + .unwrap(), + "console.log('workspace app');" + ); + } + + 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() + .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 + .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!(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] + 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("/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); + } + fn memory_staging_record_json(id: &str, claim: &str) -> String { json!({ "schema_version": 1, @@ -14387,27 +15039,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), @@ -14599,6 +15231,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(), @@ -14693,6 +15340,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( @@ -14703,6 +15368,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")) @@ -16014,6 +16697,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, @@ -16041,8 +16725,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(), @@ -16706,20 +17402,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(); @@ -16742,7 +17438,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, ); @@ -16751,7 +17447,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, ); @@ -18942,6 +19638,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..f1cf1a68 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -215,6 +215,16 @@ 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, + }, + Migration { + version: 40, + name: "create atomic Workspace catalog operations", + apply: create_workspace_catalog_operations, + }, ]; struct Migration { @@ -261,9 +271,29 @@ 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, + /// 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, +} + +#[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, @@ -579,6 +609,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, @@ -1030,7 +1064,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 +1096,15 @@ 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)?; + verify_workspace_resource_constraints(&conn)?; Ok(Self { conn: Arc::new(Mutex::new(conn)), }) @@ -1207,8 +1246,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, @@ -1217,6 +1256,7 @@ impl SqliteWorkspaceStore { revoked_at = excluded.revoked_at"#, params![ record.runtime_id, + record.workspace_id, record.display_name, record.base_url, record.public_key, @@ -1235,10 +1275,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)?; @@ -1361,10 +1401,181 @@ 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 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 + 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, @@ -1789,7 +2000,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 +2099,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 +2188,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, @@ -3938,12 +4149,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)?, }) } @@ -4960,7 +5172,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 +5331,71 @@ CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id Ok(()) } -fn validate_workspace_repository_references(conn: &Connection) -> Result<()> { +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(()); + } + 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", + "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() { + 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 +5405,225 @@ 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", + ), + ( + "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.ticket_id = event.ticket_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.ticket_id = event.ticket_id \ + AND assignment.assignment_id = event.previous_assignment_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; + } + if sql.contains("assignment.worker_id") + && !column_exists(conn, "ticket_worker_assignments", "worker_id")? + { + continue; + } + if sql.contains("current.worker_id") + && !column_exists(conn, "ticket_current_worker_assignments", "worker_id")? + { + continue; + } + collect_reference_diagnostics(conn, sql, label, &mut diagnostics)?; + } + + // Assignment and operation rows are historical soft references. Schema v39 records an + // explicit tombstone before a live Ticket or Worker parent is deleted/moved; older schemas + // have no tombstone authority, so every missing live parent remains migration-blocking drift. + if table_exists(conn, "ticket_worker_assignments")? && 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 = assignment.workspace_id \ + AND tombstone.ticket_id = assignment.ticket_id)" + } else { + "" + }; + collect_reference_diagnostics( + conn, + &format!( + "SELECT assignment.workspace_id || '/' || assignment.assignment_id || ' -> ' || 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) +} + +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 +6404,523 @@ 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_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, + 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), + UNIQUE (workspace_id, ticket_id, assignment_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +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), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +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, runtime_id, worker_id) + REFERENCES ticket_worker_assignments( + workspace_id, ticket_id, assignment_id, runtime_id, worker_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), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE +); +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}" + )) + })?; + } + // Assignment rows and events are historical evidence and intentionally survive Ticket or + // 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 ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + ) +BEGIN + SELECT RAISE(ABORT, 'ticket_worker_assignments must reference a Ticket and Worker in the same Workspace'); +END; + +CREATE TRIGGER ticket_worker_assignments_validate_update +BEFORE UPDATE OF workspace_id, ticket_id, runtime_id, worker_id ON ticket_worker_assignments +WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id + ) + OR NOT EXISTS ( + SELECT 1 FROM worker_registry AS worker + WHERE worker.workspace_id = NEW.workspace_id + AND worker.runtime_id = NEW.runtime_id + AND worker.worker_id = NEW.worker_id + ) +BEGIN + SELECT RAISE(ABORT, 'ticket_worker_assignments must reference a Ticket and Worker in the same Workspace'); +END; + +CREATE TRIGGER ticket_worker_assignment_events_validate_insert +BEFORE INSERT ON ticket_worker_assignment_events +WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.assignment_id = NEW.assignment_id + )) + OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM ticket_worker_assignments AS assignment + WHERE assignment.workspace_id = NEW.workspace_id + AND assignment.ticket_id = NEW.ticket_id + AND assignment.assignment_id = NEW.previous_assignment_id + )) +BEGIN + SELECT RAISE(ABORT, 'ticket_worker_assignment_events must reference assignments in the same Workspace'); +END; + +CREATE TRIGGER ticket_assignment_operations_validate_insert +BEFORE INSERT ON ticket_assignment_operations +WHEN NOT EXISTS ( + SELECT 1 FROM typed_tickets AS ticket + WHERE ticket.workspace_id = NEW.workspace_id AND ticket.ticket_id = NEW.ticket_id +) +BEGIN + SELECT RAISE(ABORT, 'ticket_assignment_operations must reference a Ticket in the same Workspace'); +END; +"#, + )?; + 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 +6936,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 +7432,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 +7471,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, 40); assert!(plan.migration_required); assert_eq!(plan.worker_count, 1); assert_eq!(plan.mappings[0].legacy_worker_id, 7); @@ -6496,7 +7485,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)?, 40); Ok(()) }) .unwrap(); @@ -6575,7 +7564,7 @@ mod tests { ), ] ); - assert_eq!(current_schema_version(&conn).unwrap(), 38); + 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() @@ -6647,11 +7636,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 +7693,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(), 40); assert!(!table_exists(&conn, "worker_control_delegation_operations").unwrap()); let controller_worker_id: String = conn .query_row( @@ -6817,7 +7811,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(), 40); assert!(table_exists(&conn, "worker_workdir_attachment_reservations").unwrap()); } @@ -6850,7 +7844,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(), 40); assert!(table_exists(&conn, "flow_sources").unwrap()); assert!(table_exists(&conn, "flow_source_revisions").unwrap()); assert!(!table_exists(&conn, "flow_instances").unwrap()); @@ -6917,7 +7911,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(), 40); let repositories_sql: String = conn .query_row( "SELECT sql FROM sqlite_master WHERE type = 'table' AND name = 'repositories'", @@ -7038,8 +8032,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 +8073,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 +8089,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(), 40); assert!( !store .with_conn(|conn| table_exists(conn, "worker_workspace_credentials")) @@ -7114,7 +8106,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(), 40); assert_eq!( reopened.get_workspace("local-dev").await.unwrap(), Some(record) @@ -7340,6 +8332,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 +8599,534 @@ 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 (41, 'future')", + [], + ) + .unwrap(); + + let error = apply_migrations(&conn).unwrap_err().to_string(); + assert!(error.contains("schema version 41 is newer"), "{error}"); + assert!(error.contains("refusing to serve"), "{error}"); + } + + #[test] + fn startup_rejects_missing_workspace_resource_constraint_trigger() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER ticket_worker_assignments_validate_insert")?; + Ok(()) + }) + .unwrap(); + drop(store); + + let error = match SqliteWorkspaceStore::open(&path) { + Ok(_) => panic!("missing assignment constraint trigger must fail closed"), + Err(error) => error, + }; + assert!( + error + .to_string() + .contains("ticket_worker_assignments_validate_insert"), + "{error}" + ); + } + + #[test] + fn startup_accepts_retained_assignment_history_after_parent_cleanup() { + let temp = tempfile::tempdir().unwrap(); + let path = temp.path().join("server.db"); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .with_conn(|conn| { + 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'); +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); +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' +); +INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, assigned_by, assigned_at +) VALUES ( + 'workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', + '00000000-0000-7000-8000-000000000001', 'tester', '2026-01-01' +); +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(()) + }) + .unwrap(); + drop(store); + + let reopened = SqliteWorkspaceStore::open(&path).unwrap(); + reopened + .with_conn(|conn| { + let retained: i64 = conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id = 'workspace-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(); + } + + #[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 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(); + 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), + ('workspace-b', 'ticket-b2', 'ticket-b2', 'B2', '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'); +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' +); +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-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 ( + 'workspace-b', 'ticket-a', 'assignment-cross-ticket', 'runtime-b', + '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-b', 'assignment-event-source', 'runtime-b', + '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01' +); +INSERT INTO ticket_worker_assignment_events ( + workspace_id, ticket_id, event_id, action, assignment_id, actor, created_at +) VALUES ( + 'workspace-b', 'ticket-b2', 'event-cross-ticket', 'assigned', + 'assignment-event-source', 'tester', '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!( + error.contains("ticket_worker_assignments.ticket_id"), + "{error}" + ); + assert!(error.contains("assignment-cross-ticket"), "{error}"); + assert!( + error.contains("ticket_worker_assignments.worker_id"), + "{error}" + ); + 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}" + ); + assert!(error.contains("event-cross-ticket"), "{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_assignment_events", []) + .unwrap(); + conn.execute("DELETE FROM ticket_worker_assignments", []) + .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()); + 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()); + let runtime_mismatch_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-runtime-mismatch', 'runtime-wrong', \ + '00000000-0000-7000-8000-000000000002', 'tester', '2026-01-01')", + [], + ); + assert!(runtime_mismatch_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-b', 'ticket-b2', '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'", + [], + ) + .is_err() + ); + 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 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(); + validate_workspace_resource_references(&conn).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'", + [], + ) + .unwrap(); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM typed_tickets WHERE workspace_id = 'workspace-b'", + [], + |row| row.get::<_, i64>(0), + ) + .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| { + 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 +9333,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(), 40); store .with_conn(|conn| { @@ -7979,7 +9522,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(), 40); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -8045,7 +9588,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(), 40); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: None, @@ -8436,7 +9979,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(), 40); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { account_id: "acct-user-alice".to_string(), @@ -8622,6 +10165,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..adf67b6f --- /dev/null +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -0,0 +1,394 @@ +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_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( + &self, + 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", + 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(), + require_empty_catalog, + 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() + ); + } + + #[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()); + 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")); + } +} 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..aac1c97e --- /dev/null +++ b/docs/development/workspace-schema-migrations.md @@ -0,0 +1,42 @@ +# 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 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; 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. + +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. 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. diff --git a/web/workspace/src/lib/generated/protocol.ts b/web/workspace/src/lib/generated/protocol.ts index 471f4dab..f4b03115 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, started_at_ms: number, observed_at_ms: number, last_output_at_ms: number | null, stdout: CommandStreamSlice, stderr: CommandStreamSlice, exit_code: number | null, }; + +export type CommandEvent = { "kind": "started", command_id: string, tool_call_id: string | null, observed_at_ms: number, } | { "kind": "output", command_id: string, stream: CommandStream, start_offset: number, end_offset: number, content: string, observed_at_ms: number, } | { "kind": "terminal", command_id: string, status: CommandStatus, exit_code: number | null, stdout_end_offset: number, stderr_end_offset: number, observed_at_ms: number, }; + 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": "internal_worker_removed", "data": { worker: InternalWorkerRef, revision: number, } } | { "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/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/console/model.test.ts b/web/workspace/src/lib/workspace/console/model.test.ts index 450bdc74..1005371a 100644 --- a/web/workspace/src/lib/workspace/console/model.test.ts +++ b/web/workspace/src/lib/workspace/console/model.test.ts @@ -337,6 +337,137 @@ 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", + observed_at_ms: 1000, + }, + }, + } 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", + observed_at_ms: 1100, + }, + }, + } 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", + observed_at_ms: 1200, + }, + }, + } satisfies Event, + }, + { + eventId: "command-terminal", + event: { + event: "command", + data: { + event: { + kind: "terminal", + command_id: "command-1", + status: "failed", + exit_code: 7, + stdout_end_offset: 6, + stderr_end_offset: 5, + observed_at_ms: 1300, + }, + }, + } 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("elapsed 300ms"), 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", + started_at_ms: 1000, + observed_at_ms: 1250, + last_output_at_ms: 1200, + 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("elapsed 250ms · last output at +200ms"), + 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([ { @@ -1295,7 +1426,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", @@ -1372,15 +1506,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 ea383d76..66958aa5 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, @@ -52,6 +55,7 @@ type ToolCallView = { output?: string | null; isError?: boolean; cwd?: string | null; + command?: CommandSnapshot; }; export type ConsoleDiffLine = { @@ -111,6 +115,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 = { @@ -197,6 +203,7 @@ export function emptyConsoleProjection(): ConsoleProjection { cwd: null, lastEventId: null, internalWorkers: [], + removedInternalWorkers: {}, }; } @@ -450,6 +457,135 @@ 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", + started_at_ms: event.observed_at_ms, + observed_at_ms: event.observed_at_ms, + last_output_at_ms: null, + 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", + started_at_ms: event.observed_at_ms, + observed_at_ms: event.observed_at_ms, + last_output_at_ms: event.observed_at_ms, + 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, + observed_at_ms: event.observed_at_ms, + }); + return; + } + const updatedStream = appendCommandStream( + event.stream === "stdout" ? existing.stdout : existing.stderr, + event.start_offset, + event.end_offset, + event.content, + ); + upsertCommandSnapshot(projection, eventId, { + ...existing, + observed_at_ms: event.observed_at_ms, + last_output_at_ms: event.observed_at_ms, + 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, @@ -467,6 +603,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`, @@ -503,6 +644,7 @@ export function applyProtocolEvent( cwd: projection.cwd, lastEventId: envelope.eventId, internalWorkers: [...projection.internalWorkers], + removedInternalWorkers: { ...projection.removedInternalWorkers }, }; switch (event.event) { @@ -619,12 +761,24 @@ 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) ); + 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 ); @@ -650,9 +804,25 @@ 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; + 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( @@ -1030,6 +1200,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") @@ -1038,8 +1212,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, }; } @@ -1290,9 +1464,57 @@ 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), + commandTiming(toolCall.command), + ["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 commandTiming(command?: CommandSnapshot): string | undefined { + if (!command) return undefined; + const elapsed = Math.max(0, command.observed_at_ms - command.started_at_ms); + if (command.status !== "running") return `elapsed ${durationLabel(elapsed)}`; + if (command.last_output_at_ms === null) { + return `elapsed ${durationLabel(elapsed)} · awaiting first output`; + } + const lastOutputElapsed = Math.max( + 0, + command.last_output_at_ms - command.started_at_ms, + ); + return `elapsed ${durationLabel(elapsed)} · last output at +${durationLabel(lastOutputElapsed)}`; +} + +function durationLabel(milliseconds: number): string { + if (milliseconds < 1000) return `${milliseconds}ms`; + return `${(milliseconds / 1000).toFixed(milliseconds < 10_000 ? 1 : 0)}s`; +} + +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, ]); } @@ -1545,6 +1767,7 @@ function snapshotProjectionFromEntries( cwd, lastEventId: eventId, internalWorkers: [], + removedInternalWorkers: {}, }; entries.forEach((entry, index) => applyLogEntry(projection, `${eventId}-snapshot-${index}`, entry) 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/settings/profile-api.ts b/web/workspace/src/lib/workspace/settings/profile-api.ts index 56ae0332..faea8804 100644 --- a/web/workspace/src/lib/workspace/settings/profile-api.ts +++ b/web/workspace/src/lib/workspace/settings/profile-api.ts @@ -14,7 +14,10 @@ export type WorkspaceProfileApi = { getProfiles(workspaceId: string): Promise; }; -async function requestJson(input: RequestInfo | URL, init?: RequestInit): Promise { +async function requestJson( + input: RequestInfo | URL, + init?: RequestInit, +): Promise { const response = await fetch(input, init); if (!response.ok) { throw new Error(`request failed: ${response.status}`); @@ -44,7 +47,9 @@ export async function updateWorkspaceMetadataSettings( ); } -export async function fetchProfileSettings(workspaceId: string): Promise { +export async function fetchProfileSettings( + workspaceId: string, +): Promise { return await requestJson( `/api/w/${encodeURIComponent(workspaceId)}/settings/profiles`, ); @@ -52,20 +57,12 @@ export async function fetchProfileSettings(workspaceId: string): Promise( - `/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`, - ); - }, + getMetadata: fetchWorkspaceMetadataSettings, async updateMetadata(workspaceId, displayName, expectedRevision) { - return await requestJson( - `/api/w/${encodeURIComponent(workspaceId)}/settings/metadata`, - { - method: "PUT", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ display_name: displayName, expected_revision: expectedRevision }), - }, - ); + return await updateWorkspaceMetadataSettings(workspaceId, { + display_name: displayName, + revision: expectedRevision, + }); }, getProfiles: fetchProfileSettings, }; 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} +