diff --git a/crates/agen/Cargo.toml b/crates/agen/Cargo.toml index 8da0da2d3..07a02b9f3 100644 --- a/crates/agen/Cargo.toml +++ b/crates/agen/Cargo.toml @@ -37,6 +37,7 @@ chrono = { version = "0.4", default-features = false, features = ["serde", "cloc agen-macros = { workspace = true } [dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } clap = { version = "4.5", features = ["derive", "env"] } tempfile = { workspace = true } dotenv = "0.15" diff --git a/crates/agen/src/engine.rs b/crates/agen/src/engine.rs index dd1e57e85..4031d3514 100644 --- a/crates/agen/src/engine.rs +++ b/crates/agen/src/engine.rs @@ -88,6 +88,63 @@ pub enum EngineError { ToolAttemptFence(String), } +/// Policy completion is not permission to start work when an interruption was +/// queued during its final poll. Cancel wins when both requests are observed. +fn check_policy_interruption( + cancel_rx: &mut mpsc::Receiver<()>, + pause_rx: &mut mpsc::Receiver<()>, +) -> Result<(), EngineError> { + if cancel_rx.try_recv().is_ok() { + return Err(EngineError::Cancelled); + } + if pause_rx.try_recv().is_ok() { + return Err(EngineError::PauseRequested); + } + Ok(()) +} + +/// Poll one policy invocation, never a second copy of its side effects. Dropping +/// a pending policy stops admission, not any already-owned tool execution; the +/// caller must drain that execution through its original attempt fence. +async fn await_tool_policy( + policy: impl Future>, + phase: InterceptorPhase, + cancel_rx: &mut mpsc::Receiver<()>, + pause_rx: &mut mpsc::Receiver<()>, +) -> Result { + let result = tokio::select! { + biased; + Some(()) = cancel_rx.recv() => return Err(EngineError::Cancelled), + Some(()) = pause_rx.recv() => return Err(EngineError::PauseRequested), + result = policy => result, + }; + check_policy_interruption(cancel_rx, pause_rx)?; + result.map_err(|error| InterceptorFailure::new(phase, error).into()) +} + +/// A consumed Pause still bounds post-policy processing while owned tools drain. +/// Terminal history and publication happen before this wait, so expiring it +/// cannot discard the result or reopen the execution attempt. +#[derive(Clone, Copy)] +enum PostToolPolicyWait { + Skip, + Interruptible, + UntilPauseSafeBoundary(TokioInstant), +} + +impl PostToolPolicyWait { + fn for_pause_deadline(deadline: Option) -> Self { + deadline.map_or(Self::Interruptible, Self::UntilPauseSafeBoundary) + } + + fn pause_deadline(self) -> Option { + match self { + Self::UntilPauseSafeBoundary(deadline) => Some(deadline), + _ => None, + } + } +} + /// Tool registration error #[derive(Debug, thiserror::Error)] pub enum ToolRegistryError { @@ -770,14 +827,6 @@ impl Engine { } } - fn try_paused(&mut self) -> bool { - use tokio::sync::mpsc::error::TryRecvError; - match self.pause_rx.try_recv() { - Ok(()) => true, - Err(TryRecvError::Empty | TryRecvError::Disconnected) => false, - } - } - /// Register a text block observer with scoped callbacks. /// /// The setup closure is called once per text block. Inside it, register @@ -975,10 +1024,10 @@ impl Engine { /// Register a callback invoked once per completed tool execution. /// - /// Fired after `post_tool_call` interceptors and any `content` - /// truncation from `tool_output_limits`, so the callback observes - /// exactly what is persisted to history. Intended for callers that need - /// to forward tool results to clients. + /// Fired after `content` truncation from `tool_output_limits` and terminal + /// history commit, before `post_tool_call` policy. The callback observes + /// exactly what is persisted even if that policy is interrupted. Intended + /// for callers that need to forward tool results to clients. pub fn on_tool_result(&mut self, callback: impl Fn(&ToolResult) + Send + Sync + 'static) { self.tool_result_cbs.push(Box::new(callback)); } @@ -1552,43 +1601,24 @@ impl Engine { tool, context, }; - let action = tokio::select! { - result = self.interceptor.pre_tool_call(&mut info) => { - result.map_err(|error| { - EngineError::from(InterceptorFailure::new( - InterceptorPhase::PreToolCall, - error, - )) - })? - } - pause = self.pause_rx.recv() => { - if pause.is_some() { - info!(call_id = %expected_tool_use_id, "Paused while awaiting pre-tool policy"); - } - self.append_early_tool_call( - history, - annotate, - &tool_call, - call_index, - None, - )?; + let action = await_tool_policy( + self.interceptor.pre_tool_call(&mut info), + InterceptorPhase::PreToolCall, + &mut self.cancel_rx, + &mut self.pause_rx, + ) + .await; + let action = match action { + Ok(action) => action, + Err(error @ (EngineError::PauseRequested | EngineError::Cancelled)) => { + self.append_early_tool_call(history, annotate, &tool_call, call_index, None)?; batch.dispatched_any = true; - return Ok(EarlyToolAdmission::Pause); - } - cancel = self.cancel_rx.recv() => { - if cancel.is_some() { - info!(call_id = %expected_tool_use_id, "Cancelled while awaiting pre-tool policy"); - } - self.append_early_tool_call( - history, - annotate, - &tool_call, - call_index, - None, - )?; - batch.dispatched_any = true; - return Err(EngineError::Cancelled); + return match error { + EngineError::PauseRequested => Ok(EarlyToolAdmission::Pause), + _ => Err(error), + }; } + Err(error) => return Err(error), }; if info.call.id != expected_tool_use_id { return Err(InterceptorFailure::new( @@ -1737,7 +1767,7 @@ impl Engine { result, None, batch.turn_id, - true, + PostToolPolicyWait::Interruptible, &batch.call_info_map, &mut batch.attempt_fence, &mut batch.terminal_call_ids, @@ -1764,7 +1794,7 @@ impl Engine { result, None, batch.turn_id, - false, + PostToolPolicyWait::Skip, &batch.call_info_map, &mut batch.attempt_fence, &mut batch.terminal_call_ids, @@ -1812,7 +1842,7 @@ impl Engine { result, Some(&attempt_id), batch.turn_id, - false, + PostToolPolicyWait::Skip, &batch.call_info_map, &mut batch.attempt_fence, &mut batch.terminal_call_ids, @@ -1835,7 +1865,7 @@ impl Engine { ToolResult::outcome_unknown(call_id), Some(attempt_id), batch.turn_id, - false, + PostToolPolicyWait::Skip, &batch.call_info_map, &mut batch.attempt_fence, &mut batch.terminal_call_ids, @@ -1858,28 +1888,6 @@ impl Engine { force_cancel: bool, initial_pause_requested: bool, ) -> Result { - while let Some(result) = batch.synthetic_results.pop_front() { - if let Err(error) = self - .finalize_and_commit_tool_result( - history, - annotate, - result, - None, - batch.turn_id, - true, - &batch.call_info_map, - &mut batch.attempt_fence, - &mut batch.terminal_call_ids, - ) - .await - { - let cleanup_error = self - .cancel_and_terminalize_early_batch(history, annotate, &mut batch) - .await; - return Err(cleanup_error.unwrap_or(error)); - } - } - if force_cancel { if let Some(error) = self .cancel_and_terminalize_early_batch(history, annotate, &mut batch) @@ -1893,6 +1901,28 @@ impl Engine { let mut pause_requested = initial_pause_requested; let mut pause_deadline = initial_pause_requested .then(|| TokioInstant::now() + self.tool_execution_policy.pause_safe_boundary_timeout); + while let Some(result) = batch.synthetic_results.pop_front() { + if let Err(error) = self + .finalize_and_commit_tool_result( + history, + annotate, + result, + None, + batch.turn_id, + PostToolPolicyWait::for_pause_deadline(pause_deadline), + &batch.call_info_map, + &mut batch.attempt_fence, + &mut batch.terminal_call_ids, + ) + .await + { + let cleanup_error = self + .cancel_and_terminalize_early_batch(history, annotate, &mut batch) + .await; + return Err(cleanup_error.unwrap_or(error)); + } + } + while !batch.futures.is_empty() { tokio::select! { biased; @@ -1904,7 +1934,7 @@ impl Engine { result, Some(&attempt_id), batch.turn_id, - true, + PostToolPolicyWait::for_pause_deadline(pause_deadline), &batch.call_info_map, &mut batch.attempt_fence, &mut batch.terminal_call_ids, @@ -1966,8 +1996,10 @@ impl Engine { // A pause observed before provider ownership starts leaves every call // NotStarted and therefore eligible for an explicit later retry. - if self.try_paused() { - return Ok(ToolExecutionResult::Paused); + match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) { + Ok(()) => {} + Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused), + Err(error) => return Err(error), } // Map from tool call ID to (ToolCall, Meta, Tool, Context) @@ -2006,16 +2038,18 @@ impl Engine { context, }; - let pre_tool_action = - self.interceptor - .pre_tool_call(&mut info) - .await - .map_err(|error| { - EngineError::from(InterceptorFailure::new( - InterceptorPhase::PreToolCall, - error, - )) - })?; + let pre_tool_action = match await_tool_policy( + self.interceptor.pre_tool_call(&mut info), + InterceptorPhase::PreToolCall, + &mut self.cancel_rx, + &mut self.pause_rx, + ) + .await + { + Ok(action) => action, + Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused), + Err(error) => return Err(error), + }; if info.call.id != expected_tool_use_id { return Err(InterceptorFailure::new( InterceptorPhase::PreToolCall, @@ -2082,6 +2116,14 @@ impl Engine { } } + // Recheck after all policies and immediately before provider ownership. + // No tool task exists yet: these calls remain proven NotStarted. + match check_policy_interruption(&mut self.cancel_rx, &mut self.pause_rx) { + Ok(()) => {} + Err(EngineError::PauseRequested) => return Ok(ToolExecutionResult::Paused), + Err(error) => return Err(error), + } + // Phase 2: Execute approved tools in parallel. FuturesUnordered yields // each terminal result as soon as that call completes instead of // holding fast siblings behind the slowest call in the batch. @@ -2158,7 +2200,7 @@ impl Engine { result, None, turn_id, - true, + PostToolPolicyWait::Interruptible, &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -2190,7 +2232,7 @@ impl Engine { result, Some(&attempt_id), turn_id, - true, + PostToolPolicyWait::for_pause_deadline(pause_deadline), &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -2271,7 +2313,7 @@ impl Engine { result, Some(&attempt_id), turn_id, - false, + PostToolPolicyWait::Skip, &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -2299,7 +2341,7 @@ impl Engine { ToolResult::outcome_unknown(call_id), Some(attempt_id), turn_id, - false, + PostToolPolicyWait::Skip, &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -2340,8 +2382,8 @@ impl Engine { }) } - /// Apply post-execution policy, bound the model-visible payload, durably - /// append one terminal ToolResult, and only then publish it to observers. + /// Bound the model-visible payload, durably append one terminal ToolResult, + /// publish it, then await interruptible post-execution policy. async fn finalize_and_commit_tool_result( &mut self, history: &mut History, @@ -2349,7 +2391,7 @@ impl Engine { mut tool_result: ToolResult, execution_attempt_id: Option<&str>, turn_id: usize, - invoke_post_hook: bool, + post_policy_wait: PostToolPolicyWait, call_info_map: &HashMap< String, ( @@ -2434,7 +2476,9 @@ impl Engine { ); self.emit_tool_result(&tool_result); - if invoke_post_hook && let Some((tool_call, meta, tool, context)) = call_info { + if !matches!(post_policy_wait, PostToolPolicyWait::Skip) + && let Some((tool_call, meta, tool, context)) = call_info + { let invocation = self.interceptor_invocation( InterceptorPhase::PostToolCall, Some(turn_id), @@ -2450,27 +2494,20 @@ impl Engine { tool: tool.clone(), context: context.clone(), }; + let pause_deadline = post_policy_wait.pause_deadline(); let post_tool_action = tokio::select! { - result = self.interceptor.post_tool_call(&info) => { - result.map_err(|error| { - EngineError::from(InterceptorFailure::new( - InterceptorPhase::PostToolCall, - error, - )) - })? - } - pause = self.pause_rx.recv() => { - if pause.is_some() { - info!(call_id = %tool_call.id, "Paused while awaiting post-tool policy"); - } + biased; + // Poll the existing safe-boundary deadline even when the Pause + // that established it was consumed by an outer policy gate. + _ = tokio::time::sleep_until(pause_deadline.unwrap_or_else(TokioInstant::now)), if pause_deadline.is_some() => { return Err(EngineError::PauseRequested); } - cancel = self.cancel_rx.recv() => { - if cancel.is_some() { - info!(call_id = %tool_call.id, "Cancelled while awaiting post-tool policy"); - } - return Err(EngineError::Cancelled); - } + result = await_tool_policy( + self.interceptor.post_tool_call(&info), + InterceptorPhase::PostToolCall, + &mut self.cancel_rx, + &mut self.pause_rx, + ) => result?, }; if let PostToolAction::Abort(reason) = post_tool_action { return Err(EngineError::Aborted(reason)); @@ -2508,7 +2545,7 @@ impl Engine { ToolResult::outcome_unknown(&call_id), None, self.turn_count, - false, + PostToolPolicyWait::Skip, &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -2970,22 +3007,29 @@ impl Engine { )), 0, ); - let assistant_turn_action = self - .interceptor - .on_assistant_turn_end(AssistantTurnEndContext { - invocation: assistant_invocation, - assistant_entries: &history.entries()[assistant_start..], - history: history.entries(), - tool_calls: &tool_calls, - }) - .await; + let assistant_turn_action = await_tool_policy( + self.interceptor + .on_assistant_turn_end(AssistantTurnEndContext { + invocation: assistant_invocation, + assistant_entries: &history.entries()[assistant_start..], + history: history.entries(), + tool_calls: &tool_calls, + }), + InterceptorPhase::AssistantTurnEnd, + &mut self.cancel_rx, + &mut self.pause_rx, + ) + .await; let assistant_turn_action = match assistant_turn_action { Ok(action) => action, + Err(EngineError::PauseRequested) => { + if let Some(batch) = early_tools.take() { + self.drain_early_tool_batch(history, annotate, batch, false, true) + .await?; + } + return Ok(EngineResult::Paused); + } Err(error) => { - let error = EngineError::from(InterceptorFailure::new( - InterceptorPhase::AssistantTurnEnd, - error, - )); if let Some(batch) = early_tools.take() && let Err(cleanup_error) = self .drain_early_tool_batch(history, annotate, batch, true, false) @@ -3551,7 +3595,7 @@ impl Engine { result, Some(&attempt_id), batch.turn_id, - true, + PostToolPolicyWait::Interruptible, &batch.call_info_map, &mut batch.attempt_fence, &mut batch.terminal_call_ids, diff --git a/crates/agen/src/interceptor.rs b/crates/agen/src/interceptor.rs index 5d662809b..56117c06d 100644 --- a/crates/agen/src/interceptor.rs +++ b/crates/agen/src/interceptor.rs @@ -4,6 +4,13 @@ //! keeping host-specific policy out of the Engine. Additional model input must go //! through history appends: inserting new facts only into an in-flight request //! would make its input impossible to reconstruct from stored history. +//! +//! Tool pre/post policy and assistant-turn-end waits are interruptible by Cancel or Pause; +//! Cancel wins when both are observed. Pending policy futures may be dropped, not replayed, so +//! hosts must not depend on their completion for cleanup of already-owned effects. The Engine +//! drains owned tool attempts through their original fences. Terminal tool history and result +//! publication precede post-tool policy; interruption cannot erase that result. Pause draining +//! bounds post-tool waits by the existing safe-boundary deadline. use std::sync::Arc; diff --git a/crates/agen/src/providers/codex/auth_json.rs b/crates/agen/src/providers/codex/auth_json.rs index 2d6b2b79e..51272e4bf 100644 --- a/crates/agen/src/providers/codex/auth_json.rs +++ b/crates/agen/src/providers/codex/auth_json.rs @@ -106,15 +106,23 @@ pub async fn load(path: &Path) -> Result { /// Reload the existing file, update `tokens.{id_token,access_token,refresh_token}` /// and `last_refresh`, and write it back. Equivalent to Codex CLI's `persist_tokens`. /// -/// Reload and merge before writing to preserve fields if a concurrent -/// Codex CLI / another process has already refreshed. +/// Reload and merge before writing to preserve fields updated by a concurrent +/// Codex CLI / another process. Account and refresh token must still match the +/// request's input: merge unrelated fields, but return the current snapshot +/// without writing if another login or token rotation is observed. pub async fn persist_refreshed( path: &Path, + refresh_input: &AuthSnapshot, new_id_token: Option, new_access_token: Option, new_refresh_token: Option, ) -> Result { let mut current = load(path).await?; + if current.account_id != refresh_input.account_id + || current.refresh_token != refresh_input.refresh_token + { + return Ok(current); + } let raw = &mut current.raw; let tokens = raw .get_mut("tokens") @@ -243,10 +251,16 @@ mod tests { "agent_identity":{"workspace_id":"w","agent_runtime_id":"r","agent_private_key":"k","registered_at":"x"} }"#, ); - let updated = - persist_refreshed(&path, None, Some("new-acc".into()), Some("new-ref".into())) - .await - .unwrap(); + let input = load(&path).await.unwrap(); + let updated = persist_refreshed( + &path, + &input, + None, + Some("new-acc".into()), + Some("new-ref".into()), + ) + .await + .unwrap(); assert_eq!(updated.access_token, "new-acc"); assert_eq!(updated.refresh_token, "new-ref"); // The unknown field agent_identity is preserved @@ -266,7 +280,8 @@ mod tests { ); // Change the existing file to 644, then persist → check that it returns to 600 std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap(); - persist_refreshed(&path, None, Some("a2".into()), None) + let input = load(&path).await.unwrap(); + persist_refreshed(&path, &input, None, Some("a2".into()), None) .await .unwrap(); let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777; diff --git a/crates/agen/src/providers/codex/mod.rs b/crates/agen/src/providers/codex/mod.rs index 51c98752f..73e56d48f 100644 --- a/crates/agen/src/providers/codex/mod.rs +++ b/crates/agen/src/providers/codex/mod.rs @@ -17,6 +17,8 @@ mod auth_json; mod error; mod jwt; mod refresh; +#[cfg(test)] +mod refresh_tests; use std::path::PathBuf; use std::sync::Arc; @@ -40,6 +42,9 @@ pub struct CodexAuthProvider { auth_path: PathBuf, refresh_endpoint: String, http: reqwest::Client, + refresh_limits: refresh::RefreshLimits, + #[cfg(test)] + before_guarded_reload: Option>, state: Arc>, } @@ -76,6 +81,9 @@ impl CodexAuthProvider { auth_path: codex_home.join("auth.json"), refresh_endpoint: refresh::REFRESH_URL.to_string(), http: reqwest::Client::new(), + refresh_limits: refresh::RefreshLimits::default(), + #[cfg(test)] + before_guarded_reload: None, state: Arc::new(Mutex::new(State::default())), } } @@ -106,8 +114,13 @@ impl CodexAuthProvider { return Ok(snap); } - // 3. Reload immediately before refreshing and check that account_id matches (guarded reload). - // If it differs, another process has already updated it → skip refreshing + // 3. Reload immediately before refreshing (guarded reload). + // Skip refresh if another process supplied fresh tokens or changed account_id. + #[cfg(test)] + if let Some(barrier) = &self.before_guarded_reload { + barrier.wait().await; + barrier.wait().await; + } let pre_refresh = auth_json::load(&self.auth_path).await?; if !is_stale(&pre_refresh) { state.cached = Some(pre_refresh.clone()); @@ -124,12 +137,14 @@ impl CodexAuthProvider { &self.http, &self.refresh_endpoint, &pre_refresh.refresh_token, + &self.refresh_limits, ) .await?; // 5. Write back (persist_refreshed reloads and merges before writing) let new_snap = auth_json::persist_refreshed( &self.auth_path, + &pre_refresh, refreshed.id_token, refreshed.access_token, refreshed.refresh_token, @@ -210,13 +225,18 @@ mod tests { use wiremock::matchers::{method, path as url_path}; use wiremock::{Mock, MockServer, ResponseTemplate}; - fn make_jwt(payload: serde_json::Value) -> String { + pub(super) fn make_jwt(payload: serde_json::Value) -> String { let p = base64::engine::general_purpose::URL_SAFE_NO_PAD .encode(serde_json::to_vec(&payload).unwrap()); format!("h.{p}.s") } - fn write_auth(dir: &std::path::Path, exp: i64, fedramp: bool, refresh: &str) -> PathBuf { + pub(super) fn write_auth( + dir: &std::path::Path, + exp: i64, + fedramp: bool, + refresh: &str, + ) -> PathBuf { let id_token = make_jwt(json!({ "https://api.openai.com/auth": { "chatgpt_account_id": "acc-xyz", diff --git a/crates/agen/src/providers/codex/refresh.rs b/crates/agen/src/providers/codex/refresh.rs index c74e0e829..bce8a1303 100644 --- a/crates/agen/src/providers/codex/refresh.rs +++ b/crates/agen/src/providers/codex/refresh.rs @@ -10,7 +10,26 @@ use super::error::{CodexAuthError, PermanentReason}; pub const CLIENT_ID: &str = "app_EMoamEEZ73f0CkXaXp7hrann"; pub const REFRESH_URL: &str = "https://auth.openai.com/oauth/token"; -pub const DEFAULT_REFRESH_TIMEOUT: Duration = Duration::from_secs(30); +/// Limits cover send, both response bodies, and decode, not auth file I/O. +/// OAuth responses contain only a few tokens; 64 KiB leaves ample JWT headroom. +#[derive(Clone)] +pub(super) struct RefreshLimits { + pub timeout: Duration, + pub max_body_bytes: usize, + #[cfg(test)] + pub body_started: Option>, +} + +impl Default for RefreshLimits { + fn default() -> Self { + Self { + timeout: Duration::from_secs(30), + max_body_bytes: 64 * 1024, + #[cfg(test)] + body_started: None, + } + } +} #[derive(Serialize)] struct RefreshRequest<'a> { @@ -37,54 +56,94 @@ pub async fn request_refresh( client: &reqwest::Client, endpoint: &str, refresh_token: &str, + limits: &RefreshLimits, +) -> Result { + let deadline = tokio::time::Instant::now() + limits.timeout; + let result = tokio::time::timeout_at( + deadline, + request_refresh_bounded(client, endpoint, refresh_token, limits), + ) + .await; + // JSON decode is synchronous. The byte bound limits its work, and this + // post-check prevents a decode that crosses the deadline from being persisted. + if tokio::time::Instant::now() >= deadline { + return Err(CodexAuthError::RefreshTransient( + "oauth_token_refresh timed out".into(), + )); + } + result.map_err(|_| CodexAuthError::RefreshTransient("oauth_token_refresh timed out".into()))? +} + +async fn request_refresh_bounded( + client: &reqwest::Client, + endpoint: &str, + refresh_token: &str, + limits: &RefreshLimits, ) -> Result { let body = RefreshRequest { client_id: CLIENT_ID, grant_type: "refresh_token", refresh_token, }; - let response = response_with_timeout( - client - .post(endpoint) - .header("Content-Type", "application/json") - .json(&body) - .send(), - DEFAULT_REFRESH_TIMEOUT, - ) - .await?; + let mut response = client + .post(endpoint) + .header("Content-Type", "application/json") + .json(&body) + .send() + .await + .map_err(|_| CodexAuthError::RefreshTransient("send failed".into()))?; let status = response.status(); - if status.is_success() { - response - .json::() - .await - .map_err(|e| CodexAuthError::RefreshTransient(format!("parse response: {e}"))) - } else { - let body = response.text().await.unwrap_or_default(); - if status == reqwest::StatusCode::UNAUTHORIZED { - let (reason, message) = classify_permanent(&body); - Err(CodexAuthError::RefreshPermanent { reason, message }) - } else { - Err(CodexAuthError::RefreshTransient(format!( - "{status}: {body}" - ))) + if response + .content_length() + .is_some_and(|len| len > limits.max_body_bytes as u64) + { + return Err(body_too_large()); + } + #[cfg(test)] + if let Some(started) = &limits.body_started { + started.notify_one(); + } + let mut bytes = Vec::new(); + while let Some(chunk) = response + .chunk() + .await + .map_err(|_| CodexAuthError::RefreshTransient("read response body failed".into()))? + { + // Content-Length can be absent or untrustworthy. Bound actual bytes too, + // before copying a chunk into the accumulated body. + if chunk.len() > limits.max_body_bytes.saturating_sub(bytes.len()) { + return Err(body_too_large()); } + bytes.extend_from_slice(&chunk); + } + if status.is_success() { + let tokens: RefreshResponse = serde_json::from_slice(&bytes).map_err(|_| { + // Do not expose untrusted body values (which can contain secrets). + CodexAuthError::RefreshTransient("decode response failed".into()) + })?; + if tokens.access_token.as_deref().is_none_or(str::is_empty) + || tokens.id_token.as_deref().is_some_and(str::is_empty) + || tokens.refresh_token.as_deref().is_some_and(str::is_empty) + { + return Err(CodexAuthError::RefreshTransient( + "incomplete token response".into(), + )); + } + Ok(tokens) + } else if status == reqwest::StatusCode::UNAUTHORIZED { + let (reason, message) = classify_permanent(&String::from_utf8_lossy(&bytes)); + Err(CodexAuthError::RefreshPermanent { reason, message }) + } else { + // Error bodies are consumed within the same limits but never echoed. + Err(CodexAuthError::RefreshTransient(format!( + "refresh endpoint returned {status}" + ))) } } -async fn response_with_timeout( - future: impl std::future::Future>, - timeout: Duration, -) -> Result { - tokio::time::timeout(timeout, future) - .await - .map_err(|_| { - CodexAuthError::RefreshTransient(format!( - "oauth_token_refresh timed out after {}s", - timeout.as_secs() - )) - })? - .map_err(|e| CodexAuthError::RefreshTransient(format!("send: {e}"))) +fn body_too_large() -> CodexAuthError { + CodexAuthError::RefreshTransient("response body exceeds size limit".into()) } fn classify_permanent(body: &str) -> (PermanentReason, String) { @@ -99,7 +158,7 @@ fn classify_permanent(body: &str) -> (PermanentReason, String) { PermanentReason::Expired => "Your refresh token has expired".to_string(), PermanentReason::Reused => "Your refresh token was already used".to_string(), PermanentReason::Revoked => "Your refresh token was revoked".to_string(), - PermanentReason::Other => format!("Unknown 401 from refresh endpoint: {body}"), + PermanentReason::Other => "Unknown 401 from refresh endpoint".to_string(), }; (reason, message) } @@ -126,23 +185,6 @@ fn extract_error_code(body: &str) -> Option { mod tests { use super::*; - #[tokio::test] - async fn refresh_response_timeout_is_transient() { - let err = match response_with_timeout( - std::future::pending::>(), - Duration::from_millis(5), - ) - .await - { - Ok(_) => panic!("expected refresh timeout"), - Err(err) => err, - }; - - assert!( - matches!(err, CodexAuthError::RefreshTransient(message) if message.contains("timed out")) - ); - } - #[test] fn classify_expired() { let body = r#"{"error":{"code":"refresh_token_expired"}}"#; diff --git a/crates/agen/src/providers/codex/refresh_tests.rs b/crates/agen/src/providers/codex/refresh_tests.rs new file mode 100644 index 000000000..9030f9722 --- /dev/null +++ b/crates/agen/src/providers/codex/refresh_tests.rs @@ -0,0 +1,431 @@ +//! Real AuthProvider calls against owned auth files and a loopback HTTP peer. +//! Gates place callers at response-body / reload boundaries; paused Tokio time +//! advances the injected HTTP deadline only after headers have been received. +use super::tests::{make_jwt, write_auth}; +use super::*; +use serde_json::{Value, json}; +use std::time::Duration as StdDuration; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; +use tokio::net::TcpListener; +use tokio::sync::{Notify, mpsc}; +use tokio::task::{JoinHandle, JoinSet}; + +struct Reply { + status: u16, + body: Vec, + chunked: bool, + gate: Option>, + truncate: bool, +} + +impl Reply { + fn body(status: u16, body: impl Into>) -> Self { + Self { + status, + body: body.into(), + chunked: false, + gate: None, + truncate: false, + } + } + + fn gated(status: u16, body: impl Into>, gate: &Arc) -> Self { + Self { + gate: Some(gate.clone()), + ..Self::body(status, body) + } + } +} + +struct Stub { + endpoint: String, + requests: mpsc::UnboundedReceiver, + task: JoinHandle<()>, +} + +impl Drop for Stub { + fn drop(&mut self) { + // Dropping the server's JoinSet also aborts stalled connection handlers. + self.task.abort(); + } +} + +impl Stub { + async fn start(replies: Vec) -> Self { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}/oauth/token", listener.local_addr().unwrap()); + let (tx, requests) = mpsc::unbounded_channel(); + let task = tokio::spawn(async move { + let mut handlers = JoinSet::new(); + for reply in replies { + let (mut stream, _) = listener.accept().await.unwrap(); + let tx = tx.clone(); + handlers.spawn(async move { + let mut request = Vec::new(); + let header_end = loop { + let byte = stream.read_u8().await.unwrap(); + request.push(byte); + if request.ends_with(b"\r\n\r\n") { break request.len(); } + assert!(request.len() < 16 * 1024, "fixture request headers too large"); + }; + let headers = std::str::from_utf8(&request).unwrap(); + assert!(headers.starts_with("POST /oauth/token HTTP/1.1\r\n")); + let len: usize = headers.lines().find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length").then(|| value.trim().parse().unwrap()) + }).unwrap(); + request.resize(header_end + len, 0); + stream.read_exact(&mut request[header_end..]).await.unwrap(); + tx.send(serde_json::from_slice(&request[header_end..]).unwrap()).unwrap(); + let framing = if reply.chunked { + "Transfer-Encoding: chunked".to_string() + } else { + format!("Content-Length: {}", reply.body.len()) + }; + stream.write_all(format!( + "HTTP/1.1 {} Fixture\r\nContent-Type: application/json\r\n{framing}\r\nConnection: close\r\n\r\n", reply.status + ).as_bytes()).await.unwrap(); + if let Some(gate) = reply.gate { gate.notified().await; } + let body = if reply.truncate { &reply.body[..reply.body.len() / 2] } else { &reply.body[..] }; + if reply.chunked { + for chunk in body.chunks(512) { + let _ = stream.write_all(format!("{:x}\r\n", chunk.len()).as_bytes()).await; + let _ = stream.write_all(chunk).await; + let _ = stream.write_all(b"\r\n").await; + } + let _ = stream.write_all(b"0\r\n\r\n").await; + } else { + let _ = stream.write_all(body).await; + } + }); + } + while let Some(result) = handlers.join_next().await { + result.unwrap(); + } + }); + Self { + endpoint, + requests, + task, + } + } +} + +fn rotated_body() -> Vec { + serde_json::to_vec(&json!({ + "access_token": make_jwt(json!({"exp": Utc::now().timestamp() + 3600})), + "refresh_token": "fixture-rotated-secret", + })) + .unwrap() +} + +fn provider(dir: &std::path::Path, stub: &Stub, started: &Arc) -> Arc { + let mut provider = CodexAuthProvider::new(dir.to_path_buf()) + .with_refresh_endpoint(&stub.endpoint) + .with_http_client(reqwest::Client::builder().no_proxy().build().unwrap()); + provider.refresh_limits = refresh::RefreshLimits { + timeout: StdDuration::from_secs(10), + max_body_bytes: 1024, + body_started: Some(started.clone()), + }; + Arc::new(provider) +} + +async fn bounded(future: F) -> F::Output { + tokio::time::timeout(StdDuration::from_secs(5), future) + .await + .expect("fixture/caller did not progress") +} + +fn assert_rotated(path: &std::path::Path, headers: &[(HeaderName, HeaderValue)]) { + let value: Value = serde_json::from_slice(&std::fs::read(path).unwrap()).unwrap(); + assert_eq!(value["tokens"]["refresh_token"], "fixture-rotated-secret"); + assert_eq!(value["fixture_extra"], "preserve me"); + let auth = headers + .iter() + .find(|(name, _)| name == "authorization") + .unwrap(); + assert_eq!( + auth.1.to_str().unwrap(), + format!( + "Bearer {}", + value["tokens"]["access_token"].as_str().unwrap() + ) + ); + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + assert_eq!( + std::fs::metadata(path).unwrap().permissions().mode() & 0o777, + 0o600 + ); + } +} + +fn stale_auth(dir: &std::path::Path) -> PathBuf { + let path = write_auth( + dir, + Utc::now().timestamp() - 60, + false, + "fixture-old-secret", + ); + let mut value: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + value["fixture_extra"] = json!("preserve me"); + std::fs::write(&path, serde_json::to_vec(&value).unwrap()).unwrap(); + path +} + +#[tokio::test] +async fn stalled_success_and_error_bodies_timeout_without_persist_and_release_waiting_caller() { + for status in [200, 401, 503] { + let dir = tempfile::tempdir().unwrap(); + let path = stale_auth(dir.path()); + let original = std::fs::read(&path).unwrap(); + let gate = Arc::new(Notify::new()); + let started = Arc::new(Notify::new()); + let mut stub = Stub::start(vec![ + Reply::gated(status, b"unfinished".to_vec(), &gate), + Reply::body(200, rotated_body()), + ]) + .await; + let provider = provider(dir.path(), &stub, &started); + let first = tokio::spawn({ + let provider = provider.clone(); + async move { provider.headers().await } + }); + bounded(started.notified()).await; // provider has received headers and is consuming the body + let request = bounded(stub.requests.recv()).await.unwrap(); + assert_eq!(request["refresh_token"], "fixture-old-secret"); + let second = provider.headers(); + tokio::pin!(second); + assert!(futures::poll!(&mut second).is_pending()); + assert!(provider.state.try_lock().is_err()); + assert!( + stub.requests.try_recv().is_err(), + "waiter must not refresh concurrently" + ); + tokio::time::pause(); + tokio::time::advance(StdDuration::from_secs(11)).await; + let error = first.await.unwrap().unwrap_err(); + tokio::time::resume(); + assert!(error.to_string().contains("timed out"), "{error}"); + assert_eq!(std::fs::read(&path).unwrap(), original); + let headers = bounded(second).await.unwrap(); + assert_rotated(&path, &headers); + let retry = bounded(stub.requests.recv()).await.unwrap(); + assert_eq!(retry["refresh_token"], "fixture-old-secret"); + } +} + +#[tokio::test] +async fn oversized_malformed_and_incomplete_bodies_do_not_persist_and_allow_later_refresh() { + let mut cases = vec![ + (Reply::body(200, vec![b'x'; 1025]), "size limit"), + ( + Reply { + chunked: true, + ..Reply::body(200, vec![b'x'; 1025]) + }, + "size limit", + ), + ( + Reply { + chunked: true, + ..Reply::body(401, vec![b'x'; 1025]) + }, + "size limit", + ), + (Reply::body(503, vec![b'x'; 1025]), "size limit"), + ( + Reply::body( + 200, + br#"{"access_token": {"secret":"fixture-body-secret"}}"#.to_vec(), + ), + "decode", + ), + (Reply::body(200, b"{".to_vec()), "decode"), + (Reply::body(200, b"{}".to_vec()), "incomplete"), + ( + Reply::body( + 200, + br#"{"access_token":"","refresh_token":"fixture-body-secret"}"#.to_vec(), + ), + "incomplete", + ), + ( + Reply { + truncate: true, + ..Reply::body(200, rotated_body()) + }, + "read response body", + ), + ( + Reply { + truncate: true, + ..Reply::body(401, b"fixture-body-secret".to_vec()) + }, + "read response body", + ), + ( + Reply::body(401, b"fixture-body-secret".to_vec()), + "codex login", + ), + (Reply::body(503, b"fixture-body-secret".to_vec()), "503"), + ]; + for (reply, expected) in cases.drain(..) { + let dir = tempfile::tempdir().unwrap(); + let path = stale_auth(dir.path()); + let original = std::fs::read(&path).unwrap(); + let started = Arc::new(Notify::new()); + let mut stub = Stub::start(vec![reply, Reply::body(200, rotated_body())]).await; + let provider = provider(dir.path(), &stub, &started); + let error = bounded(provider.headers()).await.unwrap_err(); + let message = error.to_string(); + assert!(message.contains(expected), "expected {expected}: {message}"); + assert!(!message.contains("fixture-body-secret")); + assert!(!message.contains("fixture-old-secret")); + assert_eq!(std::fs::read(&path).unwrap(), original); + let headers = bounded(provider.headers()).await.unwrap(); + assert_rotated(&path, &headers); + for _ in 0..2 { + assert_eq!( + bounded(stub.requests.recv()).await.unwrap()["refresh_token"], + "fixture-old-secret" + ); + } + } +} + +#[tokio::test] +async fn cancelled_body_wait_leaves_auth_unchanged_and_releases_waiting_caller() { + let dir = tempfile::tempdir().unwrap(); + let path = stale_auth(dir.path()); + let original = std::fs::read(&path).unwrap(); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let mut stub = Stub::start(vec![ + Reply::gated(200, rotated_body(), &gate), + Reply::body(200, rotated_body()), + ]) + .await; + let provider = provider(dir.path(), &stub, &started); + let first = tokio::spawn({ + let provider = provider.clone(); + async move { provider.headers().await } + }); + bounded(started.notified()).await; + bounded(stub.requests.recv()).await.unwrap(); + let second = provider.headers(); + tokio::pin!(second); + assert!(futures::poll!(&mut second).is_pending()); + assert!(stub.requests.try_recv().is_err()); + first.abort(); + assert!(first.await.unwrap_err().is_cancelled()); + assert_eq!(std::fs::read(&path).unwrap(), original); + assert_rotated(&path, &bounded(second).await.unwrap()); + assert_eq!( + bounded(stub.requests.recv()).await.unwrap()["refresh_token"], + "fixture-old-secret" + ); +} + +#[tokio::test] +async fn shared_callers_refresh_once_and_use_persisted_rotated_tokens() { + let dir = tempfile::tempdir().unwrap(); + let path = stale_auth(dir.path()); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await; + let provider = provider(dir.path(), &stub, &started); + let first = tokio::spawn({ + let provider = provider.clone(); + async move { provider.headers().await } + }); + bounded(started.notified()).await; + let second = provider.headers(); + tokio::pin!(second); + assert!(futures::poll!(&mut second).is_pending()); + assert_eq!( + bounded(stub.requests.recv()).await.unwrap()["refresh_token"], + "fixture-old-secret" + ); + let mut external: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + external["concurrent_extra"] = json!("merge me"); + std::fs::write(&path, serde_json::to_vec(&external).unwrap()).unwrap(); + gate.notify_one(); + let first_headers = bounded(first).await.unwrap().unwrap(); + let second_headers = bounded(second).await.unwrap(); + assert_eq!(first_headers, second_headers); + assert_rotated(&path, &second_headers); + let persisted: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + assert_eq!(persisted["concurrent_extra"], "merge me"); + assert!( + stub.requests.try_recv().is_err(), + "single-flight must make only one request" + ); +} + +#[tokio::test] +async fn account_switch_before_guarded_reload_skips_old_account_refresh() { + let dir = tempfile::tempdir().unwrap(); + let path = stale_auth(dir.path()); + let barrier = Arc::new(tokio::sync::Barrier::new(2)); + let mut provider = CodexAuthProvider::new(dir.path().to_path_buf()); + let mut stub = Stub::start(vec![]).await; + provider.refresh_endpoint = stub.endpoint.clone(); + provider.before_guarded_reload = Some(barrier.clone()); + let task = tokio::spawn(async move { provider.headers().await }); + bounded(barrier.wait()).await; + let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + switched["tokens"]["account_id"] = json!("switched-account"); + switched["tokens"]["refresh_token"] = json!("fixture-switched-secret"); + std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap(); + let original = std::fs::read(&path).unwrap(); + barrier.wait().await; + let headers = bounded(task).await.unwrap().unwrap(); + assert!( + headers + .iter() + .any(|(name, value)| name == "chatgpt-account-id" && value == "switched-account") + ); + assert_eq!(std::fs::read(&path).unwrap(), original); + assert!(stub.requests.try_recv().is_err()); +} + +#[tokio::test] +async fn account_or_token_switch_during_refresh_is_not_overwritten_by_old_response() { + for account_changed in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let path = stale_auth(dir.path()); + let started = Arc::new(Notify::new()); + let gate = Arc::new(Notify::new()); + let mut stub = Stub::start(vec![Reply::gated(200, rotated_body(), &gate)]).await; + let provider = provider(dir.path(), &stub, &started); + let task = tokio::spawn({ + let provider = provider.clone(); + async move { provider.headers().await } + }); + bounded(started.notified()).await; + bounded(stub.requests.recv()).await.unwrap(); + let mut switched: Value = serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + if account_changed { + switched["tokens"]["account_id"] = json!("switched-account"); + } else { + switched["tokens"]["refresh_token"] = json!("fixture-switched-secret"); + } + switched["tokens"]["access_token"] = + json!(make_jwt(json!({"exp": Utc::now().timestamp() + 7200}))); + std::fs::write(&path, serde_json::to_vec(&switched).unwrap()).unwrap(); + let original = std::fs::read(&path).unwrap(); + gate.notify_one(); + let headers = bounded(task).await.unwrap().unwrap(); + assert_eq!(std::fs::read(&path).unwrap(), original); + let expected = format!( + "Bearer {}", + switched["tokens"]["access_token"].as_str().unwrap() + ); + assert!(headers.iter().any(|(name, value)| name == "authorization" && value.to_str().unwrap() == expected)); + assert_eq!(bounded(provider.headers()).await.unwrap(), headers); + assert!(stub.requests.try_recv().is_err()); + } +} diff --git a/crates/agen/tests/parallel_execution_test.rs b/crates/agen/tests/parallel_execution_test.rs index de3156607..ae3d63e78 100644 --- a/crates/agen/tests/parallel_execution_test.rs +++ b/crates/agen/tests/parallel_execution_test.rs @@ -2873,3 +2873,737 @@ async fn post_tool_stop_terminalizes_started_parallel_siblings_before_returning( assert!(matches!(next, EngineRunExit::Finished)); } } + +// The same policy barrier is exercised through the real response-complete, +// provider fallback, interceptor fallback, and early-dispatch engine paths. +#[derive(Clone, Copy, Debug)] +enum PolicyRoute { + AfterResponse, + ProviderFallback, + InterceptorFallback, + Early, +} + +impl PolicyRoute { + fn completion_support(self) -> ToolCallCompletionSupport { + match self { + Self::ProviderFallback => ToolCallCompletionSupport::ResponseComplete, + _ => ToolCallCompletionSupport::PerBlock, + } + } + + fn configure(self, engine: &mut Engine) { + engine.set_max_turns(Some(1)); + if matches!(self, Self::AfterResponse) { + engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse); + } + } +} + +struct NotifyOnDrop(Arc); + +impl Drop for NotifyOnDrop { + fn drop(&mut self) { + self.0.notify_one(); + } +} + +#[derive(Clone)] +struct PolicyBarrier { + phase: InterceptorPhase, + early_compatible: bool, + entered: Arc, + release: Arc, + invocations: Arc, + continues: Arc, + retired: Arc, + // Enqueue from the policy's final poll, after select has polled signals. + // This deterministically exercises the policy-completion/ownership race. + signal_on_continue: Option>, +} + +impl PolicyBarrier { + fn new(phase: InterceptorPhase, route: PolicyRoute) -> Self { + Self { + phase, + early_compatible: !matches!(route, PolicyRoute::InterceptorFallback), + entered: Arc::new(tokio::sync::Notify::new()), + release: Arc::new(tokio::sync::Notify::new()), + invocations: Arc::new(AtomicUsize::new(0)), + continues: Arc::new(AtomicUsize::new(0)), + retired: Arc::new(tokio::sync::Notify::new()), + signal_on_continue: None, + } + } + + async fn wait(&self) { + let _retired = NotifyOnDrop(self.retired.clone()); + self.invocations.fetch_add(1, Ordering::SeqCst); + self.entered.notify_one(); + self.release.notified().await; + if let Some(signal) = &self.signal_on_continue { + signal.try_send(()).unwrap(); + } + self.continues.fetch_add(1, Ordering::SeqCst); + } +} + +#[async_trait] +impl Interceptor for PolicyBarrier { + fn supports_early_tool_dispatch(&self) -> bool { + self.early_compatible + } + + async fn pre_tool_call( + &self, + _info: &mut ToolCallInfo<'_>, + ) -> InterceptorResult { + if self.phase == InterceptorPhase::PreToolCall { + self.wait().await; + } + Ok(PreToolAction::Continue) + } + + async fn on_assistant_turn_end( + &self, + _context: AssistantTurnEndContext<'_>, + ) -> InterceptorResult { + if self.phase == InterceptorPhase::AssistantTurnEnd { + self.wait().await; + } + Ok(TurnEndAction::Finish) + } +} + +fn send_policy_response(tx: &tokio::sync::mpsc::UnboundedSender>) { + for event in [ + Event::tool_use_start(0, "policy_call", "policy_tool"), + Event::tool_input_delta(0, "{}"), + Event::tool_use_stop(0), + Event::usage(10, 3), + Event::Status(StatusEvent { + status: ResponseStatus::Completed, + }), + ] { + tx.send(Ok(event)).unwrap(); + } +} + +fn assert_policy_interruption(exit: &EngineRunExit, pause: bool) { + if pause { + assert!(matches!(exit, EngineRunExit::Paused), "{exit:?}"); + } else { + assert!( + matches!( + exit, + EngineRunExit::Interrupted(RunInterruptionReason::Cancelled) + ), + "{exit:?}" + ); + } +} + +fn call_and_result_count(history: &History) -> (usize, usize) { + ( + history + .items() + .filter( + |item| matches!(item, Item::ToolCall { call_id, .. } if call_id == "policy_call"), + ) + .count(), + history + .items() + .filter( + |item| matches!(item, Item::ToolResult { call_id, .. } if call_id == "policy_call"), + ) + .count(), + ) +} + +#[tokio::test] +async fn response_complete_pending_policies_interrupt_before_release_and_preserve_not_started() { + for route in [ + PolicyRoute::AfterResponse, + PolicyRoute::ProviderFallback, + PolicyRoute::InterceptorFallback, + ] { + for phase in [ + InterceptorPhase::PreToolCall, + InterceptorPhase::AssistantTurnEnd, + ] { + for pause in [false, true] { + let (client, tx) = ControlledStreamClient::new(route.completion_support()); + let mut engine = Engine::new(client); + route.configure(&mut engine); + let policy = PolicyBarrier::new(phase, route); + let probe = policy.clone(); + engine.set_interceptor(policy); + let tool = SlowTool::new("policy_tool", 0); + engine.register_tool(tool.definition()); + let usage = Arc::new(AtomicUsize::new(0)); + let usage_probe = usage.clone(); + engine.on_usage(move |_| { + usage_probe.fetch_add(1, Ordering::SeqCst); + }); + let signal = if pause { + engine.pause_sender() + } else { + engine.cancel_sender() + }; + let run = tokio::spawn(async move { + let mut history = History::new(); + let output = engine.run(&mut history, "run").await; + (output, history) + }); + send_policy_response(&tx); + drop(tx); + tokio::time::timeout(Duration::from_secs(2), probe.entered.notified()) + .await + .expect("policy entered"); + signal.send(()).await.unwrap(); + let (output, history) = tokio::time::timeout(Duration::from_secs(2), run).await + .unwrap_or_else(|_| panic!("{route:?}/{phase:?}/pause={pause}: interruption waited for policy release")) + .unwrap(); + assert_policy_interruption(&output.result, pause); + assert_eq!(tool.call_count(), 0); + assert_eq!(call_and_result_count(&history), (1, 0)); + assert!(history.items().any(|item| matches!( + item, + Item::ToolCall { + execution_id: None, + status: None | Some(agen::llm_client::ItemStatus::Completed), + .. + } + ))); + assert_eq!(usage.load(Ordering::SeqCst), 1); + assert_eq!(probe.invocations.load(Ordering::SeqCst), 1); + probe.release.notify_one(); // late permission cannot create a task + assert_eq!(probe.continues.load(Ordering::SeqCst), 0); + assert_eq!(tool.call_count(), 0); + + // Recreate both history and engine as a restore boundary. Only + // proven NotStarted is allowed to run, with normal pre-tool policy. + let mut restored_history: History = + serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap(); + let mut restored = + Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent { + status: ResponseStatus::Completed, + })])); + restored.set_max_turns(Some(1)); + restored.register_tool(tool.definition()); + let _ = restored.resume(&mut restored_history).await; + assert_eq!(tool.call_count(), 1); + assert_eq!(call_and_result_count(&restored_history), (1, 1)); + assert_eq!(usage.load(Ordering::SeqCst), 1); + } + } + } +} + +#[tokio::test] +async fn policy_final_poll_signal_prevents_provider_ownership_even_with_continue() { + for route in [ + PolicyRoute::AfterResponse, + PolicyRoute::ProviderFallback, + PolicyRoute::InterceptorFallback, + PolicyRoute::Early, + ] { + for pause in [false, true] { + let (client, tx) = ControlledStreamClient::new(route.completion_support()); + let mut engine = Engine::new(client); + route.configure(&mut engine); + let mut policy = PolicyBarrier::new(InterceptorPhase::PreToolCall, route); + policy.signal_on_continue = Some(if pause { + engine.pause_sender() + } else { + engine.cancel_sender() + }); + let probe = policy.clone(); + engine.set_interceptor(policy); + let tool = SlowTool::new("policy_tool", 0); + engine.register_tool(tool.definition()); + let run = tokio::spawn(async move { + let mut history = History::new(); + let output = engine.run(&mut history, "run").await; + (output, history) + }); + send_policy_response(&tx); + drop(tx); + tokio::time::timeout(Duration::from_secs(2), probe.entered.notified()) + .await + .expect("policy entered"); + probe.release.notify_one(); + let (output, history) = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("final-poll signal processed") + .unwrap(); + assert_policy_interruption(&output.result, pause); + assert_eq!(probe.continues.load(Ordering::SeqCst), 1); + assert_eq!(probe.invocations.load(Ordering::SeqCst), 1); + assert_eq!( + tool.call_count(), + 0, + "{route:?}/pause={pause}: Continue bypassed queued interruption" + ); + assert_eq!(call_and_result_count(&history), (1, 0)); + } + } +} + +// Provider owns the terminal channel. Cancel acknowledges receipt but does not +// manufacture a result, so tests can release a real terminal output or deliver +// it after the engine's confirmation deadline (without a live side effect). +#[derive(Clone)] +struct PolicyOwnedTool { + starts: Arc, + started: Arc, + cancel_requested: Arc, + terminal: Arc>>>, + retired: Arc, +} + +#[async_trait] +impl Tool for PolicyOwnedTool { + async fn execute(&self, _: &str, _: ToolExecutionContext) -> Result { + let _retired = NotifyOnDrop(self.retired.clone()); + self.starts.fetch_add(1, Ordering::SeqCst); + self.started.notify_one(); + Ok(self.terminal.lock().await.take().unwrap().await.unwrap()) + } + async fn cancel(&self, _: &str) -> Result<(), ToolError> { + self.cancel_requested.notify_one(); + Ok(()) + } +} + +#[tokio::test] +async fn pending_turn_end_interrupts_drain_owned_early_attempt_and_fence_late_result_on_restore() { + for pause in [false, true] { + for late in [false, true] { + let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock); + let mut engine = Engine::new(client); + PolicyRoute::Early.configure(&mut engine); + engine.set_tool_execution_policy(ToolExecutionPolicy { + pause_safe_boundary_timeout: Duration::ZERO, + cancellation_request_timeout: Duration::from_secs(1), + terminal_confirmation_timeout: if late { + Duration::ZERO + } else { + Duration::from_secs(2) + }, + }); + let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early); + let probe = policy.clone(); + engine.set_interceptor(policy); + let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel(); + let tool = PolicyOwnedTool { + starts: Arc::new(AtomicUsize::new(0)), + started: Arc::new(tokio::sync::Notify::new()), + cancel_requested: Arc::new(tokio::sync::Notify::new()), + terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))), + retired: Arc::new(tokio::sync::Notify::new()), + }; + let definition: ToolDefinition = { + let tool = tool.clone(); + Arc::new(move || { + ( + ToolMeta::new("policy_tool") + .input_schema(serde_json::json!({"type":"object"})), + Arc::new(tool.clone()) as Arc, + ) + }) + }; + engine.register_tool(definition.clone()); + let committed = Arc::new(AtomicUsize::new(0)); + let commit_probe = committed.clone(); + engine.on_history_append(move |item| { + if matches!(item, Item::ToolResult { .. }) { + commit_probe.fetch_add(1, Ordering::SeqCst); + } + Ok(()) + }); + let published = Arc::new(AtomicUsize::new(0)); + let publish_probe = published.clone(); + let commit_probe = committed.clone(); + engine.on_tool_result(move |_| { + assert_eq!( + commit_probe.load(Ordering::SeqCst), + 1, + "commit before publication" + ); + publish_probe.fetch_add(1, Ordering::SeqCst); + }); + let usage = Arc::new(AtomicUsize::new(0)); + let usage_probe = usage.clone(); + engine.on_usage(move |_| { + usage_probe.fetch_add(1, Ordering::SeqCst); + }); + let signal = if pause { + engine.pause_sender() + } else { + engine.cancel_sender() + }; + let run = tokio::spawn(async move { + let mut history = History::new(); + let output = engine.run(&mut history, "run").await; + (output, history) + }); + // Wait for ownership before completing the assistant response. + for event in [ + Event::tool_use_start(0, "policy_call", "policy_tool"), + Event::tool_input_delta(0, "{}"), + Event::tool_use_stop(0), + ] { + tx.send(Ok(event)).unwrap(); + } + tokio::time::timeout(Duration::from_secs(2), tool.started.notified()) + .await + .expect("early provider started"); + tx.send(Ok(Event::usage(10, 3))).unwrap(); + tx.send(Ok(Event::Status(StatusEvent { + status: ResponseStatus::Completed, + }))) + .unwrap(); + drop(tx); + tokio::time::timeout(Duration::from_secs(2), probe.entered.notified()) + .await + .expect("turn-end policy pending"); + signal.send(()).await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), tool.cancel_requested.notified()) + .await + .expect("owned provider cancellation progresses before policy release"); + let terminal_tx = if late { + Some(terminal_tx) + } else { + terminal_tx + .send(ToolOutput::from("confirmed after interruption".to_string())) + .unwrap(); + None + }; + let (output, history) = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("terminalization does not await turn-end policy") + .unwrap(); + assert_policy_interruption(&output.result, pause); + assert_eq!(call_and_result_count(&history), (1, 1)); + assert_eq!(committed.load(Ordering::SeqCst), 1); + assert_eq!(published.load(Ordering::SeqCst), 1); + assert_eq!(usage.load(Ordering::SeqCst), 1); + assert!(history.items().any(|item| matches!( + item, + Item::ToolCall { + execution_id: Some(_), + status: Some(agen::llm_client::ItemStatus::InProgress), + .. + } + ))); + assert!(history.items().any(|item| matches!(item, Item::ToolResult { disposition, .. } if *disposition == if late { ToolResultDisposition::OutcomeUnknown } else { ToolResultDisposition::Success }))); + if let Some(terminal_tx) = terminal_tx { + tokio::time::timeout(Duration::from_secs(2), tool.retired.notified()) + .await + .expect("forced-close retired the owned receiver"); + // Force-close retires the receiver; a provider's late completion + // cannot reenter history or replace OutcomeUnknown. + assert!( + terminal_tx + .send(ToolOutput::from("late completion".to_string())) + .is_err() + ); + } + probe.release.notify_one(); + assert_eq!(probe.continues.load(Ordering::SeqCst), 0); + let original_items = history.items().cloned().collect::>(); + let mut resumed_history = history.clone(); + let mut resumed_engine = output.engine; + let _ = resumed_engine.resume(&mut resumed_history).await; + assert_eq!( + tool.starts.load(Ordering::SeqCst), + 1, + "same-engine resume cannot rerun a terminal attempt" + ); + assert_eq!(call_and_result_count(&resumed_history), (1, 1)); + let mut restored_history: History = + serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap(); + let restored_client = MockLlmClient::new(vec![Event::Status(StatusEvent { + status: ResponseStatus::Completed, + })]); + let request_probe = restored_client.clone(); + let mut restored = Engine::new(restored_client); + restored.register_tool(definition); + restored.set_max_turns(Some(1)); + let _ = restored.resume(&mut restored_history).await; + let requests = request_probe.requests(); + assert_eq!(requests.len(), 1); + let replay = requests[0] + .items + .iter() + .filter_map(|item| match item { + Item::ToolCall { call_id, .. } => Some(format!("call:{call_id}")), + Item::ToolResult { call_id, .. } => Some(format!("result:{call_id}")), + _ => None, + }) + .collect::>(); + assert_eq!(replay, ["call:policy_call", "result:policy_call"]); + assert_eq!(tool.starts.load(Ordering::SeqCst), 1); + assert_eq!(call_and_result_count(&restored_history), (1, 1)); + assert_eq!( + restored_history + .items() + .take(original_items.len()) + .cloned() + .collect::>(), + original_items, + "restore must not rewrite original history" + ); + assert_eq!(published.load(Ordering::SeqCst), 1); + } + } +} + +#[tokio::test] +async fn pending_turn_end_pause_preserves_natural_safe_boundary_without_cancelling_started_tool() { + let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock); + let mut engine = Engine::new(client); + PolicyRoute::Early.configure(&mut engine); + engine.set_tool_execution_policy(ToolExecutionPolicy { + pause_safe_boundary_timeout: Duration::from_secs(2), + ..ToolExecutionPolicy::default() + }); + let policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early); + let probe = policy.clone(); + engine.set_interceptor(policy); + let tool = SafePauseTool::new(); + // Use this tool's actual public name in the stream fixture. + engine.register_tool(tool.definition()); + let pause = engine.pause_sender(); + let run = tokio::spawn(async move { + let mut history = History::new(); + let output = engine.run(&mut history, "run").await; + (output, history) + }); + for event in [ + Event::tool_use_start(0, "policy_call", "safe_pause"), + Event::tool_input_delta(0, "{}"), + Event::tool_use_stop(0), + Event::Status(StatusEvent { + status: ResponseStatus::Completed, + }), + ] { + tx.send(Ok(event)).unwrap(); + } + drop(tx); + tokio::time::timeout(Duration::from_secs(2), probe.entered.notified()) + .await + .expect("turn-end entered"); + pause.send(()).await.unwrap(); + tokio::time::timeout(Duration::from_secs(2), probe.retired.notified()) + .await + .expect("pending policy interrupted before tool release"); + tool.release.notify_one(); + let (output, history) = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("safe boundary collected") + .unwrap(); + assert_policy_interruption(&output.result, true); + assert_eq!(tool.calls.load(Ordering::SeqCst), 1); + assert_eq!(tool.cancellations.load(Ordering::SeqCst), 0); + assert_eq!(call_and_result_count(&history), (1, 1)); + probe.release.notify_one(); + assert_eq!(probe.continues.load(Ordering::SeqCst), 0); +} + +#[tokio::test] +async fn turn_end_final_poll_signal_wins_over_finish_without_tools() { + for pause in [false, true] { + let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock); + let mut engine = Engine::new(client); + let mut policy = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early); + policy.signal_on_continue = Some(if pause { + engine.pause_sender() + } else { + engine.cancel_sender() + }); + let probe = policy.clone(); + engine.set_interceptor(policy); + let run = tokio::spawn(async move { + let mut history = History::new(); + engine.run(&mut history, "run").await + }); + tx.send(Ok(Event::Status(StatusEvent { + status: ResponseStatus::Completed, + }))) + .unwrap(); + drop(tx); + tokio::time::timeout(Duration::from_secs(2), probe.entered.notified()) + .await + .expect("turn-end entered"); + probe.release.notify_one(); + let output = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("final-poll signal processed") + .unwrap(); + assert_policy_interruption(&output.result, pause); + assert_eq!(probe.continues.load(Ordering::SeqCst), 1); + } +} + +#[tokio::test] +async fn turn_end_pause_deadline_reclaims_started_sibling_while_post_policy_is_pending() { + struct TurnAndPostBarrier { + turn: PolicyBarrier, + post: PolicyBarrier, + } + #[async_trait] + impl Interceptor for TurnAndPostBarrier { + fn supports_early_tool_dispatch(&self) -> bool { + true + } + async fn on_assistant_turn_end( + &self, + _: AssistantTurnEndContext<'_>, + ) -> InterceptorResult { + self.turn.wait().await; + Ok(TurnEndAction::Finish) + } + async fn post_tool_call( + &self, + _: &ToolResultInfo<'_>, + ) -> InterceptorResult { + self.post.wait().await; + Ok(PostToolAction::Continue) + } + } + + let (client, tx) = ControlledStreamClient::new(ToolCallCompletionSupport::PerBlock); + let mut engine = Engine::new(client); + engine.set_tool_execution_policy(ToolExecutionPolicy { + pause_safe_boundary_timeout: Duration::from_millis(500), + cancellation_request_timeout: Duration::from_secs(1), + terminal_confirmation_timeout: Duration::from_secs(2), + }); + let turn = PolicyBarrier::new(InterceptorPhase::AssistantTurnEnd, PolicyRoute::Early); + let post = PolicyBarrier::new(InterceptorPhase::PostToolCall, PolicyRoute::Early); + engine.set_interceptor(TurnAndPostBarrier { + turn: turn.clone(), + post: post.clone(), + }); + let completed = BarrierTool::new("completed_before_pause"); + engine.register_tool(completed.definition()); + let (terminal_tx, terminal_rx) = tokio::sync::oneshot::channel(); + let sibling = PolicyOwnedTool { + starts: Arc::new(AtomicUsize::new(0)), + started: Arc::new(tokio::sync::Notify::new()), + cancel_requested: Arc::new(tokio::sync::Notify::new()), + terminal: Arc::new(tokio::sync::Mutex::new(Some(terminal_rx))), + retired: Arc::new(tokio::sync::Notify::new()), + }; + let definition: ToolDefinition = { + let sibling = sibling.clone(); + Arc::new(move || { + ( + ToolMeta::new("pending_sibling").input_schema(serde_json::json!({"type":"object"})), + Arc::new(sibling.clone()) as Arc, + ) + }) + }; + engine.register_tool(definition.clone()); + let committed = Arc::new(AtomicUsize::new(0)); + let commit_probe = committed.clone(); + engine.on_history_append(move |item| { + if matches!(item, Item::ToolResult { .. }) { + commit_probe.fetch_add(1, Ordering::SeqCst); + } + Ok(()) + }); + let published = Arc::new(AtomicUsize::new(0)); + let publish_probe = published.clone(); + let commit_probe = committed.clone(); + engine.on_tool_result(move |_| { + let n = publish_probe.fetch_add(1, Ordering::SeqCst) + 1; + assert_eq!(commit_probe.load(Ordering::SeqCst), n); + }); + let pause = engine.pause_sender(); + let run = tokio::spawn(async move { + let mut history = History::new(); + let output = engine.run(&mut history, "run").await; + (output, history) + }); + for (index, id, name) in [ + (0, "policy_call", "completed_before_pause"), + (1, "sibling_call", "pending_sibling"), + ] { + for event in [ + Event::tool_use_start(index, id, name), + Event::tool_input_delta(index, "{}"), + Event::tool_use_stop(index), + ] { + tx.send(Ok(event)).unwrap(); + } + } + tokio::time::timeout(Duration::from_secs(2), completed.started.notified()) + .await + .expect("first provider owned"); + tokio::time::timeout(Duration::from_secs(2), sibling.started.notified()) + .await + .expect("sibling provider owned"); + tx.send(Ok(Event::Status(StatusEvent { + status: ResponseStatus::Completed, + }))) + .unwrap(); + drop(tx); + tokio::time::timeout(Duration::from_secs(2), turn.entered.notified()) + .await + .expect("turn-end pending"); + pause.send(()).await.unwrap(); // only one interruption signal + tokio::time::timeout(Duration::from_secs(2), turn.retired.notified()) + .await + .expect("turn-end interrupted"); + completed.release.notify_one(); + tokio::time::timeout(Duration::from_secs(2), post.entered.notified()) + .await + .expect("completed result's post policy pending"); + assert_eq!(committed.load(Ordering::SeqCst), 1); + assert_eq!(published.load(Ordering::SeqCst), 1); + tokio::time::timeout(Duration::from_secs(2), sibling.cancel_requested.notified()).await.expect("original Pause deadline must reach sibling cancellation without releasing either policy"); + terminal_tx + .send(ToolOutput::from("confirmed sibling terminal".to_string())) + .unwrap(); + let (output, history) = tokio::time::timeout(Duration::from_secs(2), run) + .await + .expect("all owned tools reclaimed") + .unwrap(); + assert_policy_interruption(&output.result, true); + assert_eq!(committed.load(Ordering::SeqCst), 2); + assert_eq!(published.load(Ordering::SeqCst), 2); + assert_eq!( + history + .items() + .filter(|item| matches!(item, Item::ToolResult { .. })) + .count(), + 2 + ); + assert_eq!(completed.starts.load(Ordering::SeqCst), 1); + assert_eq!(sibling.starts.load(Ordering::SeqCst), 1); + assert_eq!(post.invocations.load(Ordering::SeqCst), 1); + assert_eq!(post.continues.load(Ordering::SeqCst), 0); + turn.release.notify_one(); + post.release.notify_one(); + let mut restored_history: History = + serde_json::from_str(&serde_json::to_string(&history).unwrap()).unwrap(); + let mut restored = Engine::new(MockLlmClient::new(vec![Event::Status(StatusEvent { + status: ResponseStatus::Completed, + })])); + restored.register_tool(completed.definition()); + restored.register_tool(definition); + restored.set_max_turns(Some(1)); + let _ = restored.resume(&mut restored_history).await; + assert_eq!(completed.starts.load(Ordering::SeqCst), 1); + assert_eq!(sibling.starts.load(Ordering::SeqCst), 1); + assert_eq!( + restored_history + .items() + .filter(|item| matches!(item, Item::ToolResult { .. })) + .count(), + 2 + ); +} diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 32411e020..25e8895ae 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -20,6 +20,7 @@ use ticket::{ use crate::{BackendApiClient, BackendWorkspaceClientError}; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; +const UNSUPPORTED_TICKET_STATE_CHANGE: &str = "unsupported legacy Ticket state/close operation; use TicketBackend::update_case with caller-supplied content digest, case state, closure operation key, and replay key (CAS); case updates do not start or stop work"; /// Workspace-scoped Backend client for Ticket and Objective product state. /// @@ -91,6 +92,56 @@ impl BackendWorkspaceProductClient { .map_err(|error| crate::backend_workspace::server_client_error(&self.api, error)) } + pub fn list_workspace_tags( + &self, + request: &ticket::TagListRequest, + ) -> Result, BackendWorkspaceClientError> { + let workspace_id = self.workspace_id.clone(); + let request = request.clone(); + self.generated(move |client| async move { + client + .workspace_ticket_tag_list( + workspace_id, + server_api::WorkspaceTicketTagListRequest(request), + ) + .await + .map(|response| response.0) + }) + } + + pub fn mutate_workspace_tag( + &self, + request: &ticket::WorkspaceTagMutation, + ) -> Result { + let workspace_id = self.workspace_id.clone(); + let request = request.clone(); + self.generated(move |client| async move { + client + .workspace_ticket_tag_mutate( + workspace_id, + server_api::WorkspaceTicketTagMutationRequest(request), + ) + .await + .map(|response| response.0) + }) + } + + pub fn set_ticket_tags( + &self, + id: &TicketIdOrSlug, + request: &ticket::TicketTagSetRequest, + ) -> Result, BackendWorkspaceClientError> { + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(id); + let request = request.clone(); + self.generated(move |client| async move { + client + .ticket_tags_set(workspace_id, id, server_api::TicketTagsSetRequest(request)) + .await + .map(|response| response.0) + }) + } + pub fn list_tickets( &self, query: &TicketListQuery, @@ -155,76 +206,24 @@ impl BackendWorkspaceProductClient { }) } - fn decision_from_state_change( - &self, - id: &TicketIdOrSlug, - change: &TicketStateChange, - ) -> Result { - let current = self.show_ticket(id)?; - let parse = |state: &str| { - ticket::TicketWorkflowState::parse(state).ok_or_else(|| { - BackendWorkspaceClientError::InvalidTarget(format!("invalid Ticket state {state}")) - }) - }; - Ok(ticket::TicketStateUpdate { - operation_key: uuid::Uuid::now_v7().to_string(), - expected_content_digest: ticket::ticket_content_digest(¤t), - expected_state: parse(&change.from)?, - state: parse(&change.to)?, - reason: if change.body.as_str().is_empty() { - change.reason.clone() - } else { - format!("{}\n\n{}", change.reason, change.body.as_str()) - }, - references: change.references.clone(), - author: change.author.clone(), - }) - } - pub fn set_ticket_workflow_state( &self, - id: &TicketIdOrSlug, - change: &TicketStateChange, + _id: &TicketIdOrSlug, + _change: &TicketStateChange, ) -> Result<(), BackendWorkspaceClientError> { - let change = self.decision_from_state_change(id, change)?; - let workspace_id = self.workspace_id.clone(); - let id = ticket_reference(id); - self.generated(move |client| async move { - client - .ticket_workflow_state_set( - workspace_id, - id, - server_api::TicketStateChangeRequest(change), - ) - .await - }) + Err(BackendWorkspaceClientError::InvalidTarget( + UNSUPPORTED_TICKET_STATE_CHANGE.into(), + )) } pub fn close_ticket( &self, - id: &TicketIdOrSlug, - resolution: &MarkdownText, + _id: &TicketIdOrSlug, + _resolution: &MarkdownText, ) -> Result<(), BackendWorkspaceClientError> { - let current = self.show_ticket(id)?; - let request = ticket::TicketCompletion { - operation_key: uuid::Uuid::now_v7().to_string(), - expected_content_digest: ticket::ticket_content_digest(¤t), - expected_state: current.meta.workflow_state, - reason: resolution.as_str().to_string(), - references: Vec::new(), - author: None, - }; - let workspace_id = self.workspace_id.clone(); - let id = ticket_reference(id); - self.generated(move |client| async move { - client - .ticket_close_record( - workspace_id, - id, - server_api::TicketCloseRecordRequest(request), - ) - .await - }) + Err(BackendWorkspaceClientError::InvalidTarget( + UNSUPPORTED_TICKET_STATE_CHANGE.into(), + )) } pub fn add_ticket_relation( @@ -602,6 +601,31 @@ impl BackendWorkspaceProductClient { } impl TicketBackend for BackendWorkspaceProductClient { + fn list_workspace_tags( + &self, + request: ticket::TagListRequest, + ) -> ticket::Result> { + BackendWorkspaceProductClient::list_workspace_tags(self, &request) + .map_err(ticket_client_error) + } + + fn mutate_workspace_tag( + &self, + request: ticket::WorkspaceTagMutation, + ) -> ticket::Result { + BackendWorkspaceProductClient::mutate_workspace_tag(self, &request) + .map_err(ticket_client_error) + } + + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: ticket::TicketTagSetRequest, + ) -> ticket::Result> { + BackendWorkspaceProductClient::set_ticket_tags(self, &id, &request) + .map_err(ticket_client_error) + } + fn default_intake_ready_state_change_body(&self, from: &str) -> String { let workspace_id = self.workspace_id.clone(); let from = from.to_string(); @@ -664,24 +688,12 @@ impl TicketBackend for BackendWorkspaceProductClient { fn add_state_changed( &self, - id: TicketIdOrSlug, - change: TicketStateChange, + _id: TicketIdOrSlug, + _change: TicketStateChange, ) -> ticket::Result<()> { - let change = self - .decision_from_state_change(&id, &change) - .map_err(ticket_client_error)?; - let workspace_id = self.workspace_id.clone(); - let id = ticket_reference(&id); - self.generated(move |client| async move { - client - .ticket_state_change_add( - workspace_id, - id, - server_api::TicketStateChangeRequest(change), - ) - .await - }) - .map_err(ticket_client_error) + Err(TicketError::Conflict( + UNSUPPORTED_TICKET_STATE_CHANGE.into(), + )) } fn add_intake_summary( @@ -703,59 +715,31 @@ impl TicketBackend for BackendWorkspaceProductClient { .map_err(ticket_client_error) } - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> ticket::Result<()> { - if field != "state" { - return Err(TicketError::Conflict( - "unsupported Ticket state field".into(), - )); - } - let change = self - .decision_from_state_change(&id, &change) - .map_err(ticket_client_error)?; - let workspace_id = self.workspace_id.clone(); - let id = ticket_reference(&id); - let field = field.to_string(); - self.generated(move |client| async move { - client - .ticket_state_field_set( - workspace_id, - id, - field, - server_api::TicketStateChangeRequest(change), - ) - .await - }) - .map_err(ticket_client_error) - } - - fn set_workflow_state( - &self, - id: TicketIdOrSlug, - change: TicketStateChange, - ) -> ticket::Result<()> { - self.set_ticket_workflow_state(&id, &change) - .map_err(ticket_client_error) - } - - fn update_state( + fn update_case( &self, ticket: &str, - request: ticket::TicketStateUpdate, + request: ticket::TicketCaseUpdate, ) -> ticket::Result { let workspace_id = self.workspace_id.clone(); let id = ticket.to_owned(); + // Actor identity belongs to authenticated Backend authority, not the caller. + let request = server_api::TicketCaseUpdateRequest { + expected_content_digest: request.expected_content_digest, + expected_state: request.expected_state, + expected_closure_operation_key: request.expected_closure_operation_key, + operation_key: request.operation_key, + action: request.action, + explanation: request.explanation, + references: request.references, + }; self.generated(move |client| async move { client - .ticket_state_update( - workspace_id, - id, - server_api::TicketStateUpdateRequest(request), - ) + .ticket_case_update(workspace_id.clone(), id.clone(), request) + .await?; + // The case endpoint returns only a summary. Fetch the authoritative + // full record rather than fabricating body, events, or case history. + client + .ticket_record_get(workspace_id, id) .await .map(|response| response.0) }) @@ -795,11 +779,6 @@ impl TicketBackend for BackendWorkspaceProductClient { .map_err(ticket_client_error) } - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> { - self.close_ticket(&id, &resolution) - .map_err(ticket_client_error) - } - fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -941,6 +920,33 @@ mod tests { use super::*; + fn read_http_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4_096]; + loop { + let bytes = stream.read(&mut buffer).unwrap(); + assert_ne!(bytes, 0, "incomplete HTTP request"); + request.extend_from_slice(&buffer[..bytes]); + if let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + let headers = std::str::from_utf8(&request[..header_end]).unwrap(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + return String::from_utf8(request).unwrap(); + } + } + } + } + fn one_response_server( status: &str, body: &str, @@ -952,11 +958,7 @@ mod tests { let (sender, receiver) = mpsc::channel(); let handle = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); - let mut request = vec![0_u8; 8_192]; - let bytes = stream.read(&mut request).unwrap(); - sender - .send(String::from_utf8_lossy(&request[..bytes]).to_string()) - .unwrap(); + sender.send(read_http_request(&mut stream)).unwrap(); write!( stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", @@ -980,11 +982,7 @@ mod tests { let handle = thread::spawn(move || { for (status, body) in responses { let (mut stream, _) = listener.accept().unwrap(); - let mut request = vec![0_u8; 16_384]; - let bytes = stream.read(&mut request).unwrap(); - sender - .send(String::from_utf8_lossy(&request[..bytes]).to_string()) - .unwrap(); + sender.send(read_http_request(&mut stream)).unwrap(); write!( stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", @@ -997,116 +995,357 @@ mod tests { } #[test] - fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() { - let temp = tempfile::tempdir().unwrap(); - let backend = - ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") - .unwrap(); - let reference = backend.create(NewTicket::new("Adapter snapshot")).unwrap(); - let snapshot = backend.show(reference.id.clone().into()).unwrap(); - let body = serde_json::to_string(&snapshot).unwrap(); - let (base_url, requests, handle) = - response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]); - let client = - BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token") - .unwrap(); - client - .set_ticket_workflow_state( - &reference.id.clone().into(), - &TicketStateChange::new("ready", "done", "Judgment", "Results in the thread"), - ) - .unwrap(); - assert!(requests.recv().unwrap().starts_with("GET ")); - let request = requests.recv().unwrap(); - let body: serde_json::Value = - serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = BackendWorkspaceProductClient::new_with_access_token( + format!("http://{}", listener.local_addr().unwrap()), + "workspace-a", + "token", + ) + .unwrap(); + let id = TicketIdOrSlug::Query("T-739".into()); + let change = TicketStateChange::new("open", "closed", "Judgment", "Explanation"); + let errors = [ + client + .set_ticket_workflow_state(&id, &change) + .unwrap_err() + .to_string(), + client + .close_ticket(&id, &MarkdownText::new("Withdrawn")) + .unwrap_err() + .to_string(), + TicketBackend::add_state_changed(&client, id, change) + .unwrap_err() + .to_string(), + ]; + for error in errors { + assert!( + error.contains("unsupported legacy Ticket state/close operation"), + "{error}" + ); + assert!(error.contains("TicketBackend::update_case"), "{error}"); + assert!(error.contains("caller-supplied"), "{error}"); + assert!(error.contains("CAS"), "{error}"); + } assert_eq!( - body["expected_content_digest"], - ticket::ticket_content_digest(&snapshot) + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock ); - assert_eq!(body["expected_state"], "ready"); - assert_eq!(body["state"], "done"); - assert!(!body["operation_key"].as_str().unwrap().is_empty()); - handle.join().unwrap(); } #[test] - fn legacy_close_adapter_forwards_snapshot_cas_and_resolution_as_judgment() { - let temp = tempfile::tempdir().unwrap(); - let backend = - ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") + fn serialized_update_case_forwards_tagged_actions_and_returns_full_backend_record() { + for action in [ + serde_json::json!({"action": "close", "reason": "completed"}), + serde_json::json!({"action": "close", "reason": {"other": "Custom judgment"}}), + serde_json::json!({"action": "correct_closure", "reason": "duplicate"}), + serde_json::json!({"action": "reopen"}), + ] { + // SQLite only supplies hermetic response fixtures, never a client fallback. + let temp = tempfile::tempdir().unwrap(); + let backend = + ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") + .unwrap(); + let mut input = NewTicket::new("Full case record"); + input.body = MarkdownText::new("Full body absent from the endpoint summary"); + let reference = backend.create(input).unwrap(); + let tag = backend + .mutate_workspace_tag(ticket::WorkspaceTagMutation { + operation_key: "classify-create".into(), + action: ticket::WorkspaceTagAction::Create { + name: "closed".into(), + }, + }) .unwrap(); - let reference = backend - .create(NewTicket::new("Close adapter snapshot")) - .unwrap(); - let snapshot = backend.show(reference.id.clone().into()).unwrap(); - let body = serde_json::to_string(&snapshot).unwrap(); - let (base_url, requests, handle) = - response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]); - let client = - BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token") + backend + .set_ticket_tags( + reference.id.clone().into(), + ticket::TicketTagSetRequest { + operation_key: "classify-set".into(), + expected_tag_ids: Vec::new(), + tag_ids: vec![tag.id.clone()], + }, + ) .unwrap(); - client - .close_ticket( - &reference.id.into(), - &MarkdownText::new("The request was withdrawn"), + let mut snapshot = backend.show(reference.id.clone().into()).unwrap(); + if action["action"] != "close" { + snapshot = backend + .update_case( + &reference.id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&snapshot), + expected_state: ticket::TicketState::Open, + expected_closure_operation_key: None, + operation_key: "initial-close".into(), + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + explanation: "Initial judgment".into(), + references: Vec::new(), + author: Some("authenticated-actor".into()), + }, + ) + .unwrap(); + } + let expected_request = serde_json::json!({ + "expected_content_digest": ticket::ticket_content_digest(&snapshot), + "expected_state": snapshot.meta.workflow_state.as_str(), + "expected_closure_operation_key": snapshot.closure_operation_key, + "operation_key": "caller-case-key", + "action": action, + "explanation": "Caller judgment, not work progress", + "references": [{"kind": "ticket", "target": "T-738"}], + }); + let mut domain_request = expected_request.clone(); + domain_request["author"] = serde_json::json!("caller-cannot-impersonate"); + let operation = + serde_json::from_value::(serde_json::json!({ + "operation": "update_case", + "ticket": reference.id, + "request": domain_request, + })) + .unwrap(); + let mut fixture_request: ticket::TicketCaseUpdate = + serde_json::from_value(expected_request.clone()).unwrap(); + fixture_request.author = Some("authenticated-actor".into()); + let expected = backend.update_case(&reference.id, fixture_request).unwrap(); + let summary = + serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap(); + let full_record = serde_json::to_string(&expected).unwrap(); + let (base_url, requests, handle) = + response_sequence_server(vec![("200 OK", &summary), ("200 OK", &full_record)]); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-token", ) .unwrap(); - assert!(requests.recv().unwrap().starts_with("GET ")); - let request = requests.recv().unwrap(); - let body: serde_json::Value = - serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); - assert_eq!( - body["expected_content_digest"], - ticket::ticket_content_digest(&snapshot) - ); - assert_eq!(body["expected_state"], "planning"); - assert_eq!(body["reason"], "The request was withdrawn"); - assert!(!body["operation_key"].as_str().unwrap().is_empty()); - handle.join().unwrap(); + let result = ticket::execute_ticket_backend_operation(&client, operation).unwrap(); + let ticket::TicketBackendOperationResult::Ticket(actual) = result else { + panic!("case operation must return a full Ticket"); + }; + assert_eq!(actual, expected); + assert_eq!(actual.tags, vec![tag]); + assert!(!actual.case_history.is_empty()); + assert_eq!(actual.closure_operation_key, expected.closure_operation_key); + let post = requests.recv().unwrap(); + assert!( + post.starts_with(&format!( + "POST /workspaces/workspace-a/tickets/{}/case ", + reference.id, + )), + "{post}" + ); + assert!( + post.contains("authorization: Bearer test-token\r\n"), + "{post}" + ); + let body: serde_json::Value = + serde_json::from_str(post.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body, expected_request); + assert!(body.get("author").is_none()); + assert!(requests.recv().unwrap().starts_with(&format!( + "GET /api/w/workspace-a/tickets/{}/record ", + reference.id, + ))); + handle.join().unwrap(); + } } #[test] - fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() { - let (base_url, request, handle) = one_response_server( - "409 Conflict", - r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#, - ); + fn classification_client_uses_scoped_routes_and_preserves_cas_and_replay_input() { + let tag = serde_json::json!({"workspace_id":"workspace-a", "id":"tag-a", "name":"Ready", "last_operation_key":"create-1", "deleted":false}); + let single = tag.to_string(); + let list = serde_json::json!([tag]).to_string(); + let (base_url, requests, handle) = response_sequence_server(vec![ + ("200 OK", &list), + ("200 OK", &single), + ("200 OK", &list), + ]); let client = BackendWorkspaceProductClient::new_with_access_token( base_url, "workspace-a", "test-token", ) .unwrap(); - let result = TicketBackend::complete( - &client, - "T-718", - ticket::TicketCompletion { - operation_key: "research-done".into(), - expected_content_digest: "ticket:1".into(), - expected_state: ticket::TicketWorkflowState::Planning, - reason: "Research findings recorded".into(), - references: Vec::new(), - author: None, - }, - ); - assert!(result.is_err()); - let request = request.recv().unwrap(); + let listed = client + .list_workspace_tags(&ticket::TagListRequest { + after: Some("tag-0".into()), + limit: Some(7), + }) + .unwrap(); + assert_eq!(listed[0].id, "tag-a"); + let request = requests.recv().unwrap(); assert!( - request.starts_with("POST /api/w/workspace-a/tickets/T-718/state-update "), + request.starts_with("GET /api/w/workspace-a/ticket-tags?"), + "{request}" + ); + assert!( + request.contains("after=tag-0") && request.contains("limit=7"), + "{request}" + ); + assert!( + request + .to_lowercase() + .contains("authorization: bearer test-token") + ); + let mutation = ticket::WorkspaceTagMutation { + operation_key: "rename-1".into(), + action: ticket::WorkspaceTagAction::Rename { + tag_id: "tag-a".into(), + expected_last_operation_key: "create-1".into(), + name: "New".into(), + }, + }; + TicketBackend::mutate_workspace_tag(&client, mutation.clone()).unwrap(); + let request = requests.recv().unwrap(); + assert!( + request.starts_with("POST /api/w/workspace-a/ticket-tags "), "{request}" ); let body: serde_json::Value = serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); - assert_eq!(body["state"], "done"); - assert_eq!(body["operation_key"], "research-done"); - assert_eq!(body["expected_content_digest"], "ticket:1"); - assert_eq!(body["expected_state"], "planning"); - assert_eq!(body["reason"], "Research findings recorded"); - assert!(body.get("merge_request_ids").is_none()); + assert_eq!(body, serde_json::json!(mutation)); + let set = ticket::TicketTagSetRequest { + operation_key: "set-1".into(), + expected_tag_ids: vec!["tag-old".into()], + tag_ids: vec!["tag-a".into(), "tag-b".into()], + }; + let receipt = + TicketBackend::set_ticket_tags(&client, TicketIdOrSlug::from("T-740"), set.clone()) + .unwrap(); + assert_eq!(receipt[0].name, "Ready"); + let request = requests.recv().unwrap(); + assert!( + request.starts_with("PUT /api/w/workspace-a/tickets/T-740/tags "), + "{request}" + ); + let body: serde_json::Value = + serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body, serde_json::json!(set)); handle.join().unwrap(); } + #[test] + fn classification_client_returns_backend_denials_without_local_fallback() { + for status in ["403 Forbidden", "409 Conflict"] { + let (base_url, requests, handle) = one_response_server( + status, + r#"{"error":"ticket_conflict","message":"stale or unauthorized","diagnostics":[]}"#, + ); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "token", + ) + .unwrap(); + let result = client.set_ticket_tags( + &TicketIdOrSlug::from("T-740"), + &ticket::TicketTagSetRequest { + operation_key: "retry-key".into(), + expected_tag_ids: vec![], + tag_ids: vec!["tag-a".into()], + }, + ); + assert!(result.is_err()); + assert!( + requests + .recv() + .unwrap() + .starts_with("PUT /api/w/workspace-a/tickets/T-740/tags ") + ); + handle.join().unwrap(); + } + } + + fn case_request() -> ticket::TicketCaseUpdate { + ticket::TicketCaseUpdate { + operation_key: "caller-case-key".into(), + expected_content_digest: "caller-content-digest".into(), + expected_state: ticket::TicketState::Closed, + expected_closure_operation_key: Some("last-close-key".into()), + action: ticket::TicketCaseAction::Reopen, + explanation: "Caller reopening judgment".into(), + references: Vec::new(), + author: None, + } + } + + #[test] + fn case_update_rejection_preserves_caller_cas_without_local_fallback() { + for (status, body, expected_error) in [ + ( + "409 Conflict", + r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#, + "409", + ), + ( + "403 Forbidden", + r#"{"error":"forbidden","message":"not authorized","diagnostics":[]}"#, + "403", + ), + ] { + let (base_url, requests, handle) = one_response_server(status, body); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-token", + ) + .unwrap(); + let error = TicketBackend::update_case(&client, "T-739", case_request()).unwrap_err(); + assert!( + error.to_string().contains(expected_error), + "{error}; response {status}: {body}" + ); + let request = requests.recv().unwrap(); + assert!( + request.starts_with("POST /workspaces/workspace-a/tickets/T-739/case "), + "{request}" + ); + let body: serde_json::Value = + serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body["expected_content_digest"], "caller-content-digest"); + assert_eq!(body["expected_state"], "closed"); + assert_eq!(body["expected_closure_operation_key"], "last-close-key"); + assert_eq!(body["operation_key"], "caller-case-key"); + assert_eq!(body["action"], serde_json::json!({"action": "reopen"})); + assert!(!error.to_string().contains("test-token")); + handle.join().unwrap(); + assert!(requests.try_recv().is_err()); + } + } + + #[test] + fn successful_case_update_with_failed_show_does_not_fabricate_full_ticket() { + let temp = tempfile::tempdir().unwrap(); + let backend = + ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") + .unwrap(); + let reference = backend.create(NewTicket::new("Summary only")).unwrap(); + let summary = + serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap(); + let (base_url, requests, handle) = response_sequence_server(vec![ + ("200 OK", &summary), + ( + "404 Not Found", + r#"{"error":"ticket_not_found","message":"full record unavailable","diagnostics":[]}"#, + ), + ]); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-token", + ) + .unwrap(); + let error = TicketBackend::update_case(&client, &reference.id, case_request()).unwrap_err(); + assert!(error.to_string().contains("404"), "{error}"); + assert!(requests.recv().unwrap().starts_with("POST /workspaces/")); + assert!(requests.recv().unwrap().starts_with("GET /api/w/")); + handle.join().unwrap(); + assert!(requests.try_recv().is_err()); + } + #[test] fn memory_document_uses_shared_workspace_scoped_response() { let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##; @@ -1414,12 +1653,12 @@ mod tests { assert_eq!( ticket_list_state_query(&TicketListQuery { state: TicketStateSelector::States( - [TicketListState::Ready, TicketListState::InProgress] + [TicketListState::Open, TicketListState::Closed] .into_iter() .collect(), ), }), - "ready,inprogress" + "open,closed" ); } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 60aabad97..62cddc983 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1338,7 +1338,7 @@ mod tests { assert_eq!(manifest.engine.instruction, "role.ticket_worker"); // enabled supplies ShowTicket; thread supplies TicketComment, - // TicketWorkflowState and CompleteTicket without broad workflow access. + // TicketCaseUpdate without broad workflow access. let feature = &manifest.feature; assert!(feature.ticket.enabled); assert!(feature.ticket.thread); diff --git a/crates/mcp/src/stdio.rs b/crates/mcp/src/stdio.rs index 25eb0c049..a3eb426b3 100644 --- a/crates/mcp/src/stdio.rs +++ b/crates/mcp/src/stdio.rs @@ -11,6 +11,9 @@ //! accepted only after the initialize/initialized handshake. Shutdown closes stdin, //! waits for exit within a deadline, and kills/waits to reap the process if needed. //! Startup failures also trigger cleanup; killing on Drop is a last resort. +//! A request shares one deadline across stdin ownership, write/flush, and matching response; +//! synchronous result decode is outside that wait. Interrupted write/flush closes stdin. +//! After a fully flushed frame, a missing response leaves execution unknown, not retry-safe. //! //! This module preserves Tool/resource/prompt wire values. `worker::feature::mcp` //! owns Yoi naming, schema acceptance, model output limits, and Feature registration. @@ -28,11 +31,11 @@ use secrets::SecretStore; use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use thiserror::Error; -use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt, BufReader}; -use tokio::process::{Child, ChildStderr, ChildStdin, ChildStdout, Command}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWrite, AsyncWriteExt, BufReader}; +use tokio::process::{Child, ChildStderr, ChildStdout, Command}; use tokio::sync::{Mutex, mpsc}; use tokio::task::JoinHandle; -use tokio::time::{Instant, timeout}; +use tokio::time::{Instant, timeout, timeout_at}; const MCP_PROTOCOL_VERSION: &str = "2025-11-25"; const CLIENT_NAME: &str = "yoi"; @@ -48,8 +51,11 @@ pub struct McpStdioLimits { pub max_diagnostic_lines: usize, pub max_protocol_bytes: usize, pub startup_timeout: Duration, + /// One budget for stdin ownership, frame write/flush, and response wait. pub request_timeout: Duration, + /// Shared budget for stdin ownership/close and graceful process exit. pub shutdown_timeout: Duration, + /// Budget for each fallback wait: terminate, then forced kill/reap. pub kill_timeout: Duration, } @@ -587,6 +593,30 @@ pub fn resolve_stdio_server( Ok(spec) } +// The mutex orders entire frames, including replies emitted by the stdout reader. +type SharedStdin = Arc>>>; + +/// Last boundary reached by a failed request. A flushed frame does not prove +/// that the server executed the method; callers must not blindly retry it. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum McpRequestStage { + WaitingForStdin, + WritingFrame, + FlushingFrame, + AwaitingResponse, +} + +impl fmt::Display for McpRequestStage { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(match self { + Self::WaitingForStdin => "stdin ownership (method not sent)", + Self::WritingFrame => "frame write (delivery unknown; stdin closed)", + Self::FlushingFrame => "frame flush (delivery unknown; stdin closed)", + Self::AwaitingResponse => "response (frame flushed; execution unknown; do not retry)", + }) + } +} + /// A running initialized stdio MCP client. pub struct McpStdioClient { server_name: String, @@ -594,7 +624,7 @@ pub struct McpStdioClient { redactor: Redactor, diagnostics: Arc>, list_changes: Arc>, - stdin: Arc>>, + stdin: SharedStdin, child: Option, responses: mpsc::Receiver, reader_task: JoinHandle<()>, @@ -680,7 +710,7 @@ impl McpStdioClient { ) })?; - let stdin = Arc::new(Mutex::new(Some(stdin))); + let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(stdin)))); let diagnostics = Arc::new(Mutex::new(BoundedDiagnostics::new( spec.name.clone(), limits.max_diagnostic_lines, @@ -764,6 +794,12 @@ impl McpStdioClient { /// The caller is responsible for applying Yoi tool permissions before this /// method is reached and for bounding/serializing the untrusted result before /// it is exposed to model-visible tool history. + /// + /// Timeout or cancellation after writing begins can leave execution unknown; + /// this method never retries. Interrupted write/flush closes stdin, and the + /// caller must shut down the process rather than reuse that connection. A + /// fully flushed frame permits later *different* requests with distinct IDs, + /// but is not permission to replay this call when its response is missing. pub async fn call_tool( &mut self, request: CallToolRequest, @@ -919,23 +955,35 @@ impl McpStdioClient { method, params, }; - self.write_protocol(phase, &request).await?; - let response = match timeout( - self.limits.request_timeout, - self.wait_for_response(id, phase), - ) + if self.shutdown_started { + return Err(McpClientError::new( + &self.server_name, + phase, + McpErrorKind::Io("client shutdown has started".into()), + )); + } + let deadline = Instant::now() + self.limits.request_timeout; + let mut stage = McpRequestStage::WaitingForStdin; + // One budget covers ownership, write, flush, and the matching response. + let response = match timeout_at(deadline, async { + self.write_protocol(phase, &request, &mut stage).await?; + stage = McpRequestStage::AwaitingResponse; + self.wait_for_response(id, phase).await + }) .await { - Ok(result) => result?, + Ok(Ok(response)) => response, + Ok(Err(err)) => return Err(err.with_request_stage(stage)), Err(_) => { return Err(McpClientError::new( &self.server_name, phase, McpErrorKind::Timeout { - operation: method.to_owned(), + operation: self.redactor.redact(method), elapsed: self.limits.request_timeout, }, ) + .with_request_stage(stage) .with_diagnostics(self.snapshot_diagnostics().await)); } }; @@ -1000,6 +1048,7 @@ impl McpStdioClient { method: &str, params: T, ) -> Result<(), McpClientError> { + let mut stage = McpRequestStage::WaitingForStdin; self.write_protocol( phase, &ClientNotification { @@ -1007,6 +1056,7 @@ impl McpStdioClient { method, params, }, + &mut stage, ) .await } @@ -1015,6 +1065,7 @@ impl McpStdioClient { &mut self, phase: McpPhase, value: &T, + stage: &mut McpRequestStage, ) -> Result<(), McpClientError> { write_json_line( &self.server_name, @@ -1023,6 +1074,7 @@ impl McpStdioClient { value, self.limits.max_protocol_bytes, &self.redactor, + stage, ) .await } @@ -1030,15 +1082,23 @@ impl McpStdioClient { /// Close stdin and wait for process exit, falling back to terminate and kill. pub async fn shutdown(&mut self) -> Result { self.shutdown_started = true; - { - let mut stdin = self.stdin.lock().await; - stdin.take(); - } + let deadline = Instant::now() + self.limits.shutdown_timeout; + // Ownership and EOF share one graceful budget. A reader reply may own + // stdin indefinitely; its lock is never required to terminate/kill. + let closed = timeout_at(deadline, async { + self.stdin.lock().await.take(); + }) + .await + .is_ok(); let mut child = match self.child.take() { Some(child) => child, None => return Ok(ShutdownReport::already_finished()), }; + if !closed { + self.reader_task.abort(); + return self.terminate_then_kill(child).await; + } if let Ok(Some(status)) = child.try_wait() { self.reader_task.abort(); @@ -1050,7 +1110,7 @@ impl McpStdioClient { }); } - match timeout(self.limits.shutdown_timeout, child.wait()).await { + match timeout_at(deadline, child.wait()).await { Ok(Ok(status)) => { self.reader_task.abort(); self.stderr_task.abort(); @@ -1212,12 +1272,13 @@ pub struct McpDiagnostics { } #[derive(Debug, Error, Clone)] -#[error("MCP stdio server `{server_name}` failed during phase `{phase}`: {kind}")] +#[error("MCP stdio server `{server_name}` failed during phase `{phase}`: {kind}{stage_detail}", stage_detail = request_stage.map(|stage| format!("; {stage}")).unwrap_or_default())] pub struct McpClientError { pub server_name: String, pub phase: McpPhase, pub kind: McpErrorKind, diagnostics: Option, + request_stage: Option, } impl McpClientError { @@ -1227,6 +1288,7 @@ impl McpClientError { phase, kind, diagnostics: None, + request_stage: None, } } @@ -1240,6 +1302,15 @@ impl McpClientError { self } + fn with_request_stage(mut self, stage: McpRequestStage) -> Self { + self.request_stage = Some(stage); + self + } + + pub fn request_stage(&self) -> Option { + self.request_stage + } + pub fn diagnostics(&self) -> Option<&McpDiagnostics> { self.diagnostics.as_ref() } @@ -1328,7 +1399,7 @@ struct ErrorObject<'a> { fn spawn_stdout_reader( server_name: String, stdout: ChildStdout, - stdin: Arc>>, + stdin: SharedStdin, tx: mpsc::Sender, limits: McpStdioLimits, redactor: Redactor, @@ -1385,7 +1456,7 @@ fn spawn_stdout_reader( async fn handle_incoming_message( server_name: &str, - stdin: &Arc>>, + stdin: &SharedStdin, tx: &mpsc::Sender, limits: &McpStdioLimits, redactor: &Redactor, @@ -1409,6 +1480,7 @@ async fn handle_incoming_message( &response, limits.max_protocol_bytes, redactor, + &mut McpRequestStage::WaitingForStdin, ) .await; } @@ -1501,10 +1573,11 @@ fn spawn_stderr_reader( async fn write_json_line( server_name: &str, phase: McpPhase, - stdin: &Arc>>, + stdin: &SharedStdin, value: &T, max_protocol_bytes: usize, redactor: &Redactor, + stage: &mut McpRequestStage, ) -> Result<(), McpClientError> { let mut bytes = serde_json::to_vec(value).map_err(|err| { McpClientError::new( @@ -1524,27 +1597,34 @@ async fn write_json_line( } bytes.push(b'\n'); let mut guard = stdin.lock().await; - let Some(stdin) = guard.as_mut() else { + let Some(mut writer) = guard.take() else { return Err(McpClientError::new( server_name, phase, McpErrorKind::Io("child stdin is closed".into()), )); }; - stdin.write_all(&bytes).await.map_err(|err| { + // Keep ownership out of the slot across both awaits. Timeout/cancel or + // I/O error drops the writer, closing the connection instead of allowing + // the next frame to append to an unverified partial JSON line. + *stage = McpRequestStage::WritingFrame; + writer.write_all(&bytes).await.map_err(|err| { McpClientError::new( server_name, phase, McpErrorKind::Io(redactor.redact(&err.to_string())), ) })?; - stdin.flush().await.map_err(|err| { + *stage = McpRequestStage::FlushingFrame; + writer.flush().await.map_err(|err| { McpClientError::new( server_name, phase, McpErrorKind::Io(redactor.redact(&err.to_string())), ) - }) + })?; + *guard = Some(writer); + Ok(()) } async fn read_protocol_line( @@ -1701,3 +1781,6 @@ fn send_terminate(child: &mut Child) -> Result<(), ()> { fn send_terminate(child: &mut Child) -> Result<(), ()> { child.start_kill().map_err(|_| ()) } + +#[cfg(test)] +mod deadline_tests; diff --git a/crates/mcp/src/stdio/deadline_tests.rs b/crates/mcp/src/stdio/deadline_tests.rs new file mode 100644 index 000000000..b0f3ed310 --- /dev/null +++ b/crates/mcp/src/stdio/deadline_tests.rs @@ -0,0 +1,362 @@ +//! Module-boundary fixtures exercise the same writer/request path as ChildStdin. +use super::*; +use std::future::{Future, poll_fn}; +use std::pin::Pin; +use std::sync::Mutex as StdMutex; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::task::{Context, Poll}; + +#[derive(Clone, Copy)] +enum Stall { + PartialWrite, + Flush, + Response, +} + +struct ControlledWriter { + stall: Stall, + bytes: Arc>>, + reached: Option>, + dropped: Arc, +} + +impl ControlledWriter { + fn signal(&mut self) { + if let Some(tx) = self.reached.take() { + tx.send(()).unwrap(); + } + } +} + +impl AsyncWrite for ControlledWriter { + fn poll_write( + mut self: Pin<&mut Self>, + _: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + if matches!(self.stall, Stall::PartialWrite) { + if self.bytes.lock().unwrap().is_empty() { + self.bytes.lock().unwrap().extend_from_slice(&buf[..8]); + return Poll::Ready(Ok(8)); + } + self.signal(); + return Poll::Pending; + } + self.bytes.lock().unwrap().extend_from_slice(buf); + Poll::Ready(Ok(buf.len())) + } + + fn poll_flush(mut self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + self.signal(); + if matches!(self.stall, Stall::Flush) { + Poll::Pending + } else { + Poll::Ready(Ok(())) + } + } + + fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll> { + Poll::Ready(Ok(())) + } +} + +impl Drop for ControlledWriter { + fn drop(&mut self) { + self.dropped.store(true, Ordering::SeqCst); + } +} + +fn fixture_client(stdin: SharedStdin) -> (McpStdioClient, mpsc::Sender) { + let (tx, responses) = mpsc::channel(16); + let redactor = Redactor::new(vec!["fixture-secret".into()]); + let client = McpStdioClient { + server_name: "deadline-fixture".into(), + limits: McpStdioLimits { + request_timeout: Duration::from_millis(100), + ..Default::default() + }, + diagnostics: Arc::new(Mutex::new(BoundedDiagnostics::new( + "deadline-fixture".into(), + 2, + redactor.clone(), + ))), + redactor, + list_changes: Arc::new(Mutex::new(BoundedListChanged::new( + "deadline-fixture".into(), + ))), + stdin, + child: None, + responses, + reader_task: tokio::spawn(std::future::pending()), + stderr_task: tokio::spawn(std::future::pending()), + next_id: 1, + initialized: None, + shutdown_started: false, + }; + (client, tx) +} + +async fn assert_pending(mut future: Pin<&mut F>) { + poll_fn(|cx| match future.as_mut().poll(cx) { + Poll::Pending => Poll::Ready(()), + Poll::Ready(_) => panic!("fixture boundary unexpectedly completed"), + }) + .await; +} + +#[tokio::test] +async fn stdin_ownership_timeout_and_cancel_do_not_send_a_method() { + for cancel in [false, true] { + let bytes = Arc::new(StdMutex::new(Vec::new())); + let dropped = Arc::new(AtomicBool::new(false)); + let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(ControlledWriter { + stall: Stall::Response, + bytes: bytes.clone(), + reached: None, + dropped: dropped.clone(), + })))); + let guard = stdin.lock().await; + let (mut client, _tx) = fixture_client(stdin.clone()); + let mut request = + Box::pin(client.request::(McpPhase::Running, "tools/call", json!({}))); + assert_pending(request.as_mut()).await; + if !cancel { + let err = timeout(Duration::from_secs(1), &mut request) + .await + .unwrap() + .unwrap_err(); + assert!(matches!(err.kind, McpErrorKind::Timeout { .. })); + assert_eq!(err.request_stage(), Some(McpRequestStage::WaitingForStdin)); + assert!(err.to_string().contains("method not sent")); + } + drop(request); + assert!(bytes.lock().unwrap().is_empty()); + assert!(!dropped.load(Ordering::SeqCst)); + assert!( + guard.is_some(), + "cancel before ownership must preserve the stream" + ); + } +} + +#[tokio::test] +async fn interrupted_write_or_flush_closes_stdin_before_the_next_frame() { + for (stall, stage) in [ + (Stall::PartialWrite, McpRequestStage::WritingFrame), + (Stall::Flush, McpRequestStage::FlushingFrame), + ] { + for cancel in [false, true] { + let bytes = Arc::new(StdMutex::new(Vec::new())); + let dropped = Arc::new(AtomicBool::new(false)); + let (reached, mut rx) = mpsc::unbounded_channel(); + let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(ControlledWriter { + stall, + bytes: bytes.clone(), + reached: Some(reached), + dropped: dropped.clone(), + })))); + let (mut client, _tx) = fixture_client(stdin.clone()); + let mut request = Box::pin(client.request::( + McpPhase::Running, + "tools/call", + json!({"secret": "fixture-secret"}), + )); + // Poll all the way to the controlled Pending, then observe its channel. + assert_pending(request.as_mut()).await; + timeout(Duration::from_secs(1), rx.recv()) + .await + .unwrap() + .unwrap(); + if !cancel { + let err = timeout(Duration::from_secs(1), &mut request) + .await + .unwrap() + .unwrap_err(); + assert!(matches!(err.kind, McpErrorKind::Timeout { .. })); + assert_eq!(err.request_stage(), Some(stage)); + let message = err.to_string(); + assert!(message.contains("delivery unknown; stdin closed")); + assert!(!message.contains("fixture-secret")); + } + drop(request); + assert!(dropped.load(Ordering::SeqCst)); + assert!(stdin.lock().await.is_none()); + let before = bytes.lock().unwrap().clone(); + if matches!(stall, Stall::PartialWrite) { + assert!(!before.contains(&b'\n')); + } else { + assert_eq!(before.last(), Some(&b'\n')); + } + let err = client + .request::(McpPhase::Running, "next-method", json!({})) + .await + .unwrap_err(); + assert!(err.to_string().contains("stdin is closed")); + assert_eq!( + *bytes.lock().unwrap(), + before, + "no next frame can append after interruption" + ); + } + } +} + +#[tokio::test] +async fn flushed_call_timeout_or_cancel_is_not_replayed_and_late_response_is_not_mixed() { + for cancel in [false, true] { + let bytes = Arc::new(StdMutex::new(Vec::new())); + let (reached, mut rx) = mpsc::unbounded_channel(); + let stdin: SharedStdin = Arc::new(Mutex::new(Some(Box::new(ControlledWriter { + stall: Stall::Response, + bytes: bytes.clone(), + reached: Some(reached), + dropped: Arc::new(AtomicBool::new(false)), + })))); + let (mut client, tx) = fixture_client(stdin); + let mut request = + Box::pin(client.request::(McpPhase::Running, "tools/call", json!({}))); + assert_pending(request.as_mut()).await; + timeout(Duration::from_secs(1), rx.recv()) + .await + .unwrap() + .unwrap(); + if !cancel { + let err = timeout(Duration::from_secs(1), &mut request) + .await + .unwrap() + .unwrap_err(); + assert_eq!(err.request_stage(), Some(McpRequestStage::AwaitingResponse)); + assert!(err.to_string().contains("execution unknown; do not retry")); + } + drop(request); + // Reuse is safe only because the first frame completed and IDs are + // matched. This is a different method, not a replay of the unknown call. + tx.send(ReaderEvent::Response(ServerResponse { + id: 1, + result: Some(json!({"late": true})), + error: None, + })) + .await + .unwrap(); + tx.send(ReaderEvent::Response(ServerResponse { + id: 2, + result: Some(json!({"next": true})), + error: None, + })) + .await + .unwrap(); + let result = client + .request::(McpPhase::Running, "next-method", json!({})) + .await + .unwrap(); + assert_eq!(result, json!({"next": true})); + let captured = bytes.lock().unwrap().clone(); + let frames: Vec = captured + .split(|byte| *byte == b'\n') + .filter(|line| !line.is_empty()) + .map(|line| serde_json::from_slice(line).unwrap()) + .collect(); + assert_eq!(frames.len(), 2); + assert_eq!(frames[0]["method"], "tools/call"); + assert_eq!(frames[1]["method"], "next-method"); + } +} + +// Observes actual OS-pipe backpressure, not a guessed delay or just a mock +// Pending. It forwards every operation to ChildStdin without changing framing. +struct PendingObserver { + writer: W, + accepted: usize, + reached: Option>, +} +impl AsyncWrite for PendingObserver { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll> { + let result = Pin::new(&mut self.writer).poll_write(cx, buf); + if let Poll::Ready(Ok(count)) = &result { + self.accepted += count; + } + if result.is_pending() && self.accepted > 1024 { + if let Some(tx) = self.reached.take() { + tx.send(()).unwrap(); + } + } + result + } + fn poll_flush(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.writer).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + Pin::new(&mut self.writer).poll_shutdown(cx) + } +} + +#[cfg(unix)] +#[tokio::test] +async fn blocked_reader_reply_does_not_prevent_shutdown_process_reaping() { + // A local stdio fixture: initialize, consume initialized, emit an unsupported + // server request whose echoed ID exceeds pipe capacity, then never read. + // The shell has no descendants and ignores TERM to also prove kill fallback. + let init = json!({"jsonrpc":"2.0", "id":1, "result": {"protocolVersion": MCP_PROTOCOL_VERSION, "capabilities":{}, "serverInfo":{"name":"fixture", "version":"1"}}}); + let server_request = + json!({"jsonrpc":"2.0", "id":"x".repeat(256 * 1024), "method":"sampling/createMessage"}); + // Avoid argv limits: generate the large protocol ID in the shell itself. + let script = format!( + "trap '' TERM; read -r init; printf '%s\\n' '{init}'; read -r initialized; printf '%s' '{{\"jsonrpc\":\"2.0\",\"method\":\"sampling/createMessage\",\"id\":\"'; i=0; while [ $i -lt 4096 ]; do printf '%s' '{}'; i=$((i+1)); done; printf '%s\\n' '\"}}'; while :; do :; done", + "x".repeat(64) + ); + assert!(server_request.to_string().len() < McpStdioLimits::default().max_stdout_line_bytes); + let limits = McpStdioLimits { + shutdown_timeout: Duration::from_millis(50), + kill_timeout: Duration::from_millis(100), + ..Default::default() + }; + let mut client = McpStdioClient::spawn( + McpStdioServerSpec::new("reader-blocked", "/bin/sh") + .arg("-c") + .arg(script), + limits, + ) + .await + .unwrap(); + let pid = client.child.as_ref().unwrap().id().unwrap(); + let (reached, mut rx) = mpsc::unbounded_channel(); + { + let mut guard = client.stdin.lock().await; + let writer = guard.take().unwrap(); + *guard = Some(Box::new(PendingObserver { + writer, + accepted: 0, + reached: Some(reached), + })); + } + client.initialize().await.unwrap(); + timeout(Duration::from_secs(3), rx.recv()) + .await + .expect("reader reply reached OS pipe Pending") + .unwrap(); + assert!( + client.stdin.try_lock().is_err(), + "reader owns stdin while its reply is blocked" + ); + let report = timeout(Duration::from_secs(1), client.shutdown()) + .await + .unwrap() + .unwrap(); + assert!(report.terminated && report.killed); + assert!(report.exit_status.is_some()); + assert!(client.child.is_none()); + // Tokio wait already reaped the child; an additional wait cannot find it. + let mut status = 0; + assert_eq!( + unsafe { libc::waitpid(pid as libc::pid_t, &mut status, libc::WNOHANG) }, + -1 + ); + assert_eq!( + std::io::Error::last_os_error().raw_os_error(), + Some(libc::ECHILD) + ); +} diff --git a/crates/mcp/tests/fixtures/mock_server.rs b/crates/mcp/tests/fixtures/mock_server.rs index 9f2893e0e..48cee6fbd 100644 --- a/crates/mcp/tests/fixtures/mock_server.rs +++ b/crates/mcp/tests/fixtures/mock_server.rs @@ -6,8 +6,11 @@ use std::time::Duration; use serde_json::{Value, json}; fn main() { - let mode = env::var("YOI_MCP_MOCK_MODE").unwrap_or_else(|_| "success".to_string()); + let mode = env::args() + .nth(1) + .unwrap_or_else(|| env::var("YOI_MCP_MOCK_MODE").unwrap_or_else(|_| "success".to_string())); match mode.as_str() { + "stdin-unread" => stdin_unread(), "success" => success(), "tools" => tools_list(), "tools-call-normal" => tools_call_normal(), @@ -22,6 +25,20 @@ fn main() { } } +fn stdin_unread() { + let init = read_json(); + assert_eq!(init["method"], "initialize"); + write_json(json!({"jsonrpc": "2.0", "id": init["id"], "result": initialize_result()})); + assert_eq!(read_json()["method"], "notifications/initialized"); + // Protocol barrier: the host only writes its oversized call after this ack. + let ready = read_json(); + assert_eq!(ready["method"], "fixture/ready"); + write_json(json!({"jsonrpc": "2.0", "id": ready["id"], "result": {"ready": true}})); + loop { + thread::park(); + } +} + fn success() { let init = read_json(); assert_eq!(init["method"], "initialize"); diff --git a/crates/mcp/tests/stdio_lifecycle.rs b/crates/mcp/tests/stdio_lifecycle.rs index 4cb0e8aa2..77b96ebdc 100644 --- a/crates/mcp/tests/stdio_lifecycle.rs +++ b/crates/mcp/tests/stdio_lifecycle.rs @@ -278,3 +278,48 @@ async fn sampling_requests_fail_closed_and_are_not_advertised() { let shutdown = client.shutdown().await.expect("shutdown succeeds"); assert!(shutdown.exit_status.is_some_and(|status| status.success())); } + +#[tokio::test] +async fn unread_stdin_request_times_out_during_write_and_closes_before_reuse() { + let limits = McpStdioLimits { + request_timeout: Duration::from_millis(100), + ..tight_limits() + }; + let spec = McpStdioServerSpec::new("unread-stdin", env!("CARGO_BIN_EXE_mcp-stdio-mock-server")) + .arg("stdin-unread"); + let mut client = McpStdioClient::connect(spec, limits).await.unwrap(); + let ready: serde_json::Value = client + .request(McpPhase::Running, "fixture/ready", serde_json::json!({})) + .await + .unwrap(); + assert_eq!(ready["ready"], true); + // Far beyond OS pipe capacity, but inside the existing protocol bound. + let call = CallToolRequest::new( + "write-blocked", + serde_json::json!({"payload": "x".repeat(768 * 1024)}), + ); + let err = tokio::time::timeout(Duration::from_secs(1), client.call_tool(call)) + .await + .expect("the whole request, not only response wait, is bounded") + .unwrap_err(); + assert!( + matches!(err.kind, McpErrorKind::Timeout { ref operation, .. } if operation == "tools/call") + ); + assert_eq!( + err.request_stage(), + Some(mcp::stdio::McpRequestStage::WritingFrame) + ); + // The fixture barrier and reported stage establish actual write backpressure; + // the separate controlled fixture also verifies the exact partial bytes. + let next = client + .request::(McpPhase::Running, "fixture/next", serde_json::json!({})) + .await + .unwrap_err(); + assert!(next.to_string().contains("stdin is closed")); + let report = tokio::time::timeout(Duration::from_secs(1), client.shutdown()) + .await + .unwrap() + .unwrap(); + assert!(report.terminated || report.killed); + assert!(report.exit_status.is_some()); +} diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index d6da1151d..5a390a764 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -610,7 +610,9 @@ pub struct CompleteMergeRequest { pub now: DateTime, } /// Historical MR-owned Ticket completion payload, retained for reading existing events. -/// Canonical Ticket completion is owned by the Ticket domain. +/// Current close/reopen/correction judgments belong to the Ticket domain and require no +/// universal MR evidence gate. This archived payload is neither a current case operation nor +/// authority to infer closure, self-reported handling, work release, or Worker termination. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketCompletionEvent { pub operation_id: String, @@ -1171,18 +1173,9 @@ impl MergeRequestStore { "approval predates the current Ticket digest; fresh review is required".into(), )); } - let state: Option = connection - .query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", - params![mr.workspace_id, i.ticket_id], - |row| row.get(0), - ) - .optional()?; - if state.as_deref() != Some("inprogress") { - return Err(MergeRequestError::Conflict( - "Ticket must be inprogress".into(), - )); - } + // Case openness is not an integration phase. Exact source approval, + // current Ticket content and active assignment fencing above remain + // the independent MR integration guards. Ok(()) } @@ -1254,18 +1247,9 @@ impl MergeRequestStore { "approval predates the current Ticket digest; fresh review is required".into(), )); } - let state: Option = transaction - .query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", - params![mr.workspace_id, i.ticket_id], - |row| row.get(0), - ) - .optional()?; - if state.as_deref() != Some("inprogress") { - return Err(MergeRequestError::Conflict( - "Ticket must be inprogress".into(), - )); - } + // Case openness is not an integration phase. Exact source approval, + // current Ticket content and active assignment fencing above remain + // the independent MR integration guards. let issued_grants = { let mut statement = transaction.prepare( "SELECT request_event_id,subject_ref,capability_token diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index dc2e7567e..bbbf4799c 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -302,14 +302,11 @@ impl_openapi_schema!( SkillDetailResponse, SkillActivationResponse, BrowserAppendTicketEventRequest, - BrowserCloseTicketRequest, BrowserEditTicketRequest, BrowserQueueTicketRequest, - BrowserTransitionTicketStateRequest, CancelTicketImplementationRequest, ClearTicketRoleAssignmentQuery, CompleteMergeRequestRequest, - CompleteTicketRequest, CreateTicketOrchestrationPlanRequest, CreateTicketRecordRequest, CreateTicketRelationRequest, @@ -343,9 +340,16 @@ impl_openapi_schema!( SetTicketRoleAssignmentRequest, SubmitMergeRequestReviewRequest, TextResponse, - TicketCloseRecordRequest, TicketDependencyCheckResponse, TicketDetail, + TicketCaseUpdateRequest, + TicketAssignmentTrackingUpdateRequest, + TicketAssignmentTrackingRecord, + TicketAssignmentTrackingList, + TicketAssignmentTrackingQuery, + TicketAssignmentTrackingHistoryQuery, + TicketAssignmentTrackingHistoryPage, + TicketSummary, TicketDoctorResponse, TicketIntakeSummaryRequest, TicketListHttpQuery, @@ -354,6 +358,12 @@ impl_openapi_schema!( TicketOrchestrationPlanRecord, TicketOrchestrationPlanRecordList, TicketOrchestrationPlanSearchRequest, + TicketAssignmentsQuery, + TicketAssignmentsPage, + TicketAssignmentAnswerRequest, + TicketAssignmentAnswerResponse, + TicketAssignmentDiagnosticsQuery, + TicketAssignmentDiagnosticsPage, TicketQueryRequest, TicketQueryResponse, TicketQueueResponse, @@ -368,10 +378,13 @@ impl_openapi_schema!( TicketRoleAssignmentMutationResponse, TicketRoleAssignmentsResponse, TicketShowRequest, - TicketStateChangeRequest, - TicketStateUpdateRequest, TicketSummarySearchQuery, TicketThreadEventRequest, + WorkspaceTicketTagListRequest, + WorkspaceTicketTagMutationRequest, + TicketTagsSetRequest, + WorkspaceTicketTagList, + WorkspaceTicketTag, RuntimeManagementApiError, RuntimeWorkersQuery, RuntimeWorkerSpawnRequest, @@ -1977,6 +1990,7 @@ pub trait ServerApi { )] async fn runtime_worker_create_alias( &self, + #[extension] context: ServerRequestContext, #[path] runtime_id: String, #[body] request: RuntimeWorkerSpawnRequest, ) -> Result; @@ -2006,6 +2020,7 @@ pub trait ServerApi { )] async fn runtime_worker_create( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] runtime_id: String, #[body] request: RuntimeWorkerSpawnRequest, @@ -2983,6 +2998,31 @@ pub trait ServerApi { #[body] request: CreateTicketRecordRequest, ) -> Result; + #[get("/api/w/{workspace_id}/ticket-tags", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn workspace_ticket_tag_list( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[query] request: WorkspaceTicketTagListRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/ticket-tags", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn workspace_ticket_tag_mutate( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[body] request: WorkspaceTicketTagMutationRequest, + ) -> Result; + + #[put("/api/w/{workspace_id}/tickets/{id}/tags", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_tags_set( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: TicketTagsSetRequest, + ) -> Result; + #[post("/api/w/{workspace_id}/tickets/query", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn ticket_query( &self, @@ -3058,14 +3098,6 @@ pub trait ServerApi { #[path] id: String, #[body] request: TicketThreadEventRequest, ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_state_change_add( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: TicketStateChangeRequest, - ) -> Result<(), RepositoryApiError>; #[post("/api/w/{workspace_id}/tickets/{id}/intake-summaries", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_intake_summary_add( &self, @@ -3074,31 +3106,6 @@ pub trait ServerApi { #[path] id: String, #[body] request: TicketIntakeSummaryRequest, ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_state_field_set( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[path] field: String, - #[body] request: TicketStateChangeRequest, - ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_workflow_state_set( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: TicketStateChangeRequest, - ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/state-update", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_state_update( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: TicketStateUpdateRequest, - ) -> Result; #[post("/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_mark_ready_record( &self, @@ -3196,22 +3203,92 @@ pub trait ServerApi { #[path] id: String, #[body] request: CompleteMergeRequestRequest, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_complete( + #[post("/workspaces/{workspace_id}/tickets/{ticket_id}/case", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_case_update( &self, #[extension] context: ServerRequestContext, #[path] workspace_id: String, - #[path] id: String, - #[body] request: CompleteTicketRequest, - ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_close_record( + #[path] ticket_id: String, + #[body] request: TicketCaseUpdateRequest, + ) -> Result; + #[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignments_page( &self, #[extension] context: ServerRequestContext, #[path] workspace_id: String, - #[path] id: String, - #[body] request: TicketCloseRecordRequest, - ) -> Result<(), RepositoryApiError>; + #[path] ticket_id: String, + #[query] query: TicketAssignmentsQuery, + ) -> Result; + + #[get("/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignments_page_browser( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentsQuery, + ) -> Result; + + #[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_diagnostics( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentDiagnosticsQuery, + ) -> Result; + + #[get("/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_diagnostics_browser( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentDiagnosticsQuery, + ) -> Result; + + #[post("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_answer( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[body] request: TicketAssignmentAnswerRequest, + ) -> Result; + + #[post("/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_answer_browser( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[body] request: TicketAssignmentAnswerRequest, + ) -> Result; + + #[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_tracking_get( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentTrackingQuery, + ) -> Result; + #[post("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_tracking_update( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[body] request: TicketAssignmentTrackingUpdateRequest, + ) -> Result; + #[get("/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_assignment_tracking_history( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] ticket_id: String, + #[query] query: TicketAssignmentTrackingHistoryQuery, + ) -> Result; #[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn ticket_relation_view( &self, @@ -3276,6 +3353,7 @@ pub trait ServerApi { #[put("/api/w/{workspace_id}/tickets/{id}/assignments/{role}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_assignment_set( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] id: String, #[path] role: String, @@ -3285,6 +3363,7 @@ pub trait ServerApi { #[delete("/api/w/{workspace_id}/tickets/{id}/assignments/{role}", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_assignment_clear( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] id: String, #[path] role: String, @@ -3299,15 +3378,6 @@ pub trait ServerApi { #[body] request: CancelTicketImplementationRequest, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_state_transition( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: BrowserTransitionTicketStateRequest, - ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_ready( &self, @@ -3334,14 +3404,6 @@ pub trait ServerApi { #[body] request: BrowserQueueTicketRequest, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_close( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: BrowserCloseTicketRequest, - ) -> Result; #[get("/api/objectives", status = 200, error_status = 400, additional_error_statuses = [500], bearer_auth = true, browser_auth = true)] async fn objective_list_alias( &self, @@ -3610,6 +3672,9 @@ pub struct ServerWorkerSource { pub struct ServerRuntimeSource { pub runtime_id: String, pub worker_id: Option, + /// Binding whose public key authenticated this request. Retain the verified + /// identity through mutation CAS/replay checks; a later lookup is not proof. + pub authentication_binding_id: String, } /// Request context built by Server authentication adapters, not authority selected via public JSON. @@ -7274,16 +7339,29 @@ pub struct InvalidProjectRecord { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct TicketListItemSummary { + #[serde(default)] + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub tags: Vec, pub id: String, pub resource_key: String, pub title: String, - pub state: String, + pub state: BrowserTicketState, pub priority: String, pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, pub workspace_action_priority: String, pub record_source: String, + /// Last closure/correction key, retained across reopening for ABA-safe CAS. + pub closure_operation_key: Option, + #[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))] + pub current_closure: Option, + /// Original migration handoff; not another current state authority. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketLegacyWorkflowSnapshot | null") + )] + pub legacy_handoff: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7567,20 +7645,74 @@ pub struct TicketEvidenceSummary { pub missing: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum TicketAttention { + UnresolvedReview, + MissingCommit, + Blocked, + Unblocked, + AwaitingReview, + UnresolvedChanges, + StaleAfterRescope, + MissingEvidence, +} + +impl TicketAttention { + pub const fn as_str(&self) -> &'static str { + match self { + Self::UnresolvedReview => "unresolved_review", + Self::MissingCommit => "missing_commit", + Self::Blocked => "blocked", + Self::Unblocked => "unblocked", + Self::AwaitingReview => "awaiting_review", + Self::UnresolvedChanges => "unresolved_changes", + Self::StaleAfterRescope => "stale_after_rescope", + Self::MissingEvidence => "missing_evidence", + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct TicketQueryRequest { + /// Exact reason of the current closed-case judgment; reopened/history-only reasons do not match. + #[cfg_attr(feature = "typescript", ts(type = "TicketClosureReason | null"))] + pub closure_reason: Option, + /// Exact current assigned principal; retained relationships do not match. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketAssignmentPrincipalSnapshot | null") + )] + pub assigned_principal: Option, + /// Explicit self-reported handling only; never inferred from Worker state. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketAssignmentTrackingStatus | null") + )] + pub handling_status: Option, pub query: Option, #[serde(default)] - pub states: Vec, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub states: Vec, #[serde(default)] pub event_kinds: Vec, #[serde(default)] pub evidence: Vec, pub review_status: Option, #[serde(default)] - pub attention: Vec, + pub attention: Vec, + /// Match tickets containing every supplied tag ID (AND). + #[serde(default)] + pub tag_ids: Vec, + /// Match tickets containing at least one supplied tag ID (OR). + #[serde(default)] + pub tag_ids_any: Vec, + /// Exclude tickets containing any supplied tag ID (NOT). + #[serde(default)] + pub tag_ids_none: Vec, pub related_ticket_id: Option, pub relation_kind: Option, pub linked_objective_id: Option, @@ -7597,9 +7729,11 @@ pub struct TicketQueryRequest { #[serde(deny_unknown_fields)] pub struct TicketQueryItem { pub id: String, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub tags: Vec, pub resource_key: String, pub title: String, - pub state: String, + pub state: BrowserTicketState, pub readiness: Option, pub priority: String, pub created_at: Option, @@ -7623,10 +7757,25 @@ pub struct TicketQueryItem { #[cfg_attr(feature = "typescript", ts(type = "number"))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_usize))] pub unresolved_review_count: usize, + #[cfg_attr( + feature = "typescript", + ts(type = "Array") + )] + pub assignment_tracking: Vec, pub evidence: TicketEvidenceSummary, pub merge_requests: Vec, pub merge_request: Option, pub current_worker: Option, + /// Last closure/correction key, retained across reopening for ABA-safe CAS. + pub closure_operation_key: Option, + #[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))] + pub current_closure: Option, + /// Original migration handoff; not another current state authority. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketLegacyWorkflowSnapshot | null") + )] + pub legacy_handoff: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7652,9 +7801,11 @@ pub struct TicketShowRequest { #[serde(deny_unknown_fields)] pub struct TicketDetail { pub id: String, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub tags: Vec, pub resource_key: String, pub title: String, - pub state: String, + pub state: BrowserTicketState, pub readiness: Option, pub priority: String, pub created_at: Option, @@ -7680,6 +7831,11 @@ pub struct TicketDetail { pub linked_objectives: Vec, pub implementation_reports: Vec, pub assignments: Vec, + #[cfg_attr( + feature = "typescript", + ts(type = "Array") + )] + pub assignment_tracking: Vec, pub current_worker: Option, pub assignment_diagnostics: Vec, pub action_eligibility: TicketActionEligibility, @@ -7688,6 +7844,18 @@ pub struct TicketDetail { pub evidence: TicketEvidenceSummary, pub resolution: Option, pub record_source: String, + /// Last closure/correction key, retained across reopening for ABA-safe CAS. + pub closure_operation_key: Option, + #[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))] + pub current_closure: Option, + /// Original migration handoff; not another current state authority. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketLegacyWorkflowSnapshot | null") + )] + pub legacy_handoff: Option, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub closure_history: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7717,6 +7885,145 @@ macro_rules! transparent_ticket_dto { } transparent_ticket_dto!(TicketRecord, ticket::Ticket); +transparent_ticket_dto!(TicketSummary, ticket::TicketSummary); + +/// Authorized case judgment. Author comes from authenticated Workspace authority, +/// never from the request. All three actions share content/state/closure-key CAS. +fn deserialize_case_closure_observation<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::::deserialize(deserializer) +} + +fn case_closure_observation_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + generator.subschema_for::>() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketCaseUpdateRequest { + pub expected_content_digest: String, + pub expected_state: ticket::TicketState, + #[serde(deserialize_with = "deserialize_case_closure_observation")] + #[schemars(required, schema_with = "case_closure_observation_schema")] + pub expected_closure_operation_key: Option, + pub operation_key: String, + pub action: ticket::TicketCaseAction, + pub explanation: String, + #[serde(default)] + pub references: Vec, +} +transparent_ticket_dto!( + TicketAssignmentTrackingUpdateRequest, + ticket::TicketAssignmentTrackingUpdate +); +transparent_ticket_dto!( + TicketAssignmentTrackingRecord, + ticket::TicketAssignmentTrackingProjection +); +transparent_ticket_dto!( + TicketAssignmentTrackingList, + Vec +); + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingQuery { + /// Omit for the current delegation; provide to inspect a retained assignment. + pub assignment_id: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingHistoryQuery { + pub assignment_id: String, + pub after_event_id: Option, + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingHistoryPage { + pub items: Vec, + pub next_event_id: Option, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentsQuery { + pub after_assignment_id: Option, + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} +transparent_ticket_dto!(TicketAssignmentsPage, ticket::TicketAssignmentsPage); +transparent_ticket_dto!( + TicketAssignmentAnswerRequest, + ticket::TicketAssignmentAnswer +); +transparent_ticket_dto!( + TicketAssignmentAnswerResponse, + ticket::TicketAssignmentAnswerReceipt +); + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentDiagnosticsQuery { + pub assignment_id: String, + pub after_input_event_id: Option, + pub after_start_operation_id: Option, + #[schemars(range(min = 1, max = 100))] + pub limit: Option, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TicketAssignmentInputDeliveryState { + NotReserved, + Reserved, + RequestPersisted, + RuntimeAccepted, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct TicketAssignmentInputDiagnostic { + pub input_event_id: String, + pub delivery_id: Option, + pub notification: TicketAssignmentInputDeliveryState, + /// Absence of a Runtime ack is not proof of failed external delivery. + pub acceptance_outcome_unknown: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct TicketAssignmentStartDiagnostic { + pub operation_id: String, + /// Durable start/assignment reservation result, not Worker liveness. + pub claim_state: String, + pub assignment_id: Option, + pub runtime_id: Option, + pub worker_id: Option, + /// Existing records do not attest that a model processed a particular input. + pub actual_processing_unknown: bool, +} +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +pub struct TicketAssignmentDiagnosticsPage { + pub assignment_id: String, + pub current: bool, + pub input_origin_known: bool, + /// Noncurrent/done/closed/released records are history, not pending work. + pub actionable: bool, + pub inputs: Vec, + pub next_input_event_id: Option, + /// Assignment-scoped admission evidence; not attributed to individual input. + pub starts: Vec, + pub next_start_operation_id: Option, +} + +transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest); +transparent_ticket_dto!( + WorkspaceTicketTagMutationRequest, + ticket::WorkspaceTagMutation +); +transparent_ticket_dto!(TicketTagsSetRequest, ticket::TicketTagSetRequest); +transparent_ticket_dto!(WorkspaceTicketTagList, Vec); +transparent_ticket_dto!(WorkspaceTicketTag, ticket::WorkspaceTag); transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef); transparent_ticket_dto!(TicketRecordSummaryList, Vec); transparent_ticket_dto!(TicketDependencyCheckResponse, ticket::TicketDependencyCheck); @@ -7736,10 +8043,7 @@ transparent_ticket_dto!( transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket); transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit); transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent); -transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateUpdate); -transparent_ticket_dto!(TicketStateUpdateRequest, ticket::TicketStateUpdate); transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary); -transparent_ticket_dto!(TicketCloseRecordRequest, ticket::TicketCompletion); transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation); transparent_ticket_dto!( CreateTicketOrchestrationPlanRequest, @@ -7794,6 +8098,10 @@ pub struct TicketWorkerAttachmentBinding { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct SetTicketRoleAssignmentRequest { + /// Observed delegation required for Orchestrator Ticket starts; manual starts omit it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub orchestrator_assignment_id: Option, + pub operation_id: String, pub principal: TicketAssignmentPrincipal, pub expected_assignment_id: Option, @@ -7851,15 +8159,29 @@ pub enum BrowserTicketTargetEdit { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] -pub enum BrowserTicketWorkflowState { - Planning, - Ready, - Queued, - Inprogress, - Done, +pub enum BrowserTicketState { + Open, Closed, } +impl BrowserTicketState { + pub const fn as_str(&self) -> &'static str { + match self { + Self::Open => "open", + Self::Closed => "closed", + } + } +} + +impl From for BrowserTicketState { + fn from(state: ticket::TicketState) -> Self { + match state { + ticket::TicketState::Open => Self::Open, + ticket::TicketState::Closed => Self::Closed, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -7874,18 +8196,6 @@ pub struct BrowserEditTicketRequest { pub author: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -#[serde(deny_unknown_fields)] -pub struct BrowserTransitionTicketStateRequest { - pub state: BrowserTicketWorkflowState, - pub operation_key: String, - pub expected_content_digest: String, - pub expected_state: BrowserTicketWorkflowState, - pub reason: String, - pub body: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] @@ -7910,16 +8220,6 @@ pub struct BrowserAppendTicketEventRequest { #[serde(deny_unknown_fields)] pub struct BrowserQueueTicketRequest {} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -#[serde(deny_unknown_fields)] -pub struct BrowserCloseTicketRequest { - pub operation_key: String, - pub expected_content_digest: String, - pub expected_state: BrowserTicketWorkflowState, - pub resolution: String, -} - #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ObjectiveListQuery { @@ -8292,17 +8592,6 @@ pub struct CompleteMergeRequestRequest { pub resolution: ConflictResolution, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CompleteTicketRequest { - pub operation_key: String, - pub expected_content_digest: String, - pub expected_state: ticket::TicketWorkflowState, - pub reason: String, - #[serde(default)] - pub references: Vec, -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RuntimeManagementApiError { @@ -8453,6 +8742,10 @@ pub enum RuntimeWorkerSpawnAcceptanceRequirement { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RuntimeWorkerTicketAssignmentRequest { + /// Observed delegation required for Orchestrator Ticket starts; manual starts omit it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub orchestrator_assignment_id: Option, + pub ticket_id: String, pub operation_id: String, } @@ -9782,6 +10075,10 @@ pub struct BrowserWorkerWorkingDirectorySelection { #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct CreateWorkspaceWorkerTicketAssignmentRequest { + /// Observed delegation required for Orchestrator Ticket starts; manual starts omit it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub orchestrator_assignment_id: Option, + pub ticket_id: String, pub operation_id: String, } @@ -11083,6 +11380,210 @@ mod skill_typescript_tests { mod tests { use super::*; + #[test] + fn ticket_tag_query_filters_default_empty_and_roundtrip() { + let request: TicketQueryRequest = serde_json::from_value(serde_json::json!({})).unwrap(); + assert!(request.tag_ids.is_empty()); + assert!(request.tag_ids_any.is_empty()); + assert!(request.tag_ids_none.is_empty()); + let request: TicketQueryRequest = serde_json::from_value(serde_json::json!({ + "states": ["open", "closed"], + "tag_ids": ["tag-a", "tag-b"], + "tag_ids_any": ["tag-c", "tag-d"], + "tag_ids_none": ["tag-e"] + })) + .unwrap(); + let value = serde_json::to_value(request).unwrap(); + assert_eq!(value["tag_ids"], serde_json::json!(["tag-a", "tag-b"])); + assert_eq!(value["tag_ids_any"], serde_json::json!(["tag-c", "tag-d"])); + assert_eq!(value["tag_ids_none"], serde_json::json!(["tag-e"])); + assert_eq!(value["states"], serde_json::json!(["open", "closed"])); + } + + #[test] + fn ticket_tag_mutations_require_operation_keys_and_observed_cas() { + for action in [ + serde_json::json!({"kind": "create", "name": "bug"}), + serde_json::json!({"kind": "rename", "tag_id": "tag-a", "expected_last_operation_key": "observed", "name": "defect"}), + serde_json::json!({"kind": "delete", "tag_id": "tag-a", "expected_last_operation_key": "observed"}), + ] { + let value = serde_json::json!({"operation_key": "new-operation", "action": action}); + let parsed: WorkspaceTicketTagMutationRequest = + serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), value); + let mut missing_operation = value.clone(); + missing_operation + .as_object_mut() + .unwrap() + .remove("operation_key"); + assert!( + serde_json::from_value::(missing_operation) + .is_err() + ); + if action["kind"] != "create" { + let mut missing_cas = value; + missing_cas["action"] + .as_object_mut() + .unwrap() + .remove("expected_last_operation_key"); + assert!( + serde_json::from_value::(missing_cas) + .is_err() + ); + } + } + let value = serde_json::json!({ + "operation_key": "replace-tags", + "expected_tag_ids": ["tag-a", "tag-b"], + "tag_ids": ["tag-b", "tag-c"] + }); + let parsed: TicketTagsSetRequest = serde_json::from_value(value.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), value); + for key in ["operation_key", "expected_tag_ids", "tag_ids"] { + let mut missing = value.clone(); + missing.as_object_mut().unwrap().remove(key); + assert!( + serde_json::from_value::(missing).is_err(), + "{key}" + ); + } + } + + #[test] + fn ticket_tag_list_and_responses_are_transparent() { + let query = serde_json::json!({"after": "tag-a", "limit": 200}); + let parsed: WorkspaceTicketTagListRequest = serde_json::from_value(query.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), query); + let tag = serde_json::json!({ + "workspace_id": "workspace-a", "id": "tag-a", "name": "bug", + "last_operation_key": "create-tag", "deleted": false + }); + let parsed: WorkspaceTicketTag = serde_json::from_value(tag.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), tag); + let list = serde_json::json!([tag]); + let parsed: WorkspaceTicketTagList = serde_json::from_value(list.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), list); + for schema in [ + schemars::schema_for!(TicketDetail), + schemars::schema_for!(TicketQueryItem), + schemars::schema_for!(TicketListItemSummary), + ] { + let schema = serde_json::to_value(schema).unwrap(); + assert_eq!(schema["properties"]["tags"]["type"], "array"); + assert_eq!( + schema["properties"]["tags"]["items"]["$ref"], + "#/$defs/WorkspaceTag" + ); + for field in ["closure_operation_key", "current_closure", "legacy_handoff"] { + assert!(schema["properties"].get(field).is_some(), "{field}"); + } + assert_eq!( + schema["$defs"]["BrowserTicketState"]["enum"], + serde_json::json!(["open", "closed"]) + ); + } + let detail = serde_json::to_value(schemars::schema_for!(TicketDetail)).unwrap(); + assert!(detail["properties"]["closure_history"].is_object()); + } + + #[test] + fn ticket_classification_requests_reject_case_judgments_and_actor_claims() { + let mutation = serde_json::json!({"operation_key":"classify", "action":{"kind":"create", "name":"closed"}}); + let set = serde_json::json!({"operation_key":"classify", "expected_tag_ids":[], "tag_ids":["tag-1"]}); + for (field, value) in [ + ("author", serde_json::json!("orchestrator")), + ("expected_state", serde_json::json!("closed")), + ("expected_content_digest", serde_json::json!("digest")), + ("reason", serde_json::json!("completed")), + ] { + let mut invalid_mutation = mutation.clone(); + invalid_mutation[field] = value.clone(); + assert!( + serde_json::from_value::(invalid_mutation) + .is_err(), + "mutation {field}" + ); + let mut invalid_set = set.clone(); + invalid_set[field] = value; + assert!( + serde_json::from_value::(invalid_set).is_err(), + "set {field}" + ); + } + let query = serde_json::json!({"states":["closed"], "tag_ids":["tag-1"], "tag_ids_any":["tag-2"], "tag_ids_none":["tag-3"]}); + assert!(serde_json::from_value::(query.clone()).is_ok()); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "invalid", + ] { + let mut invalid = query.clone(); + invalid["states"] = serde_json::json!([state]); + assert!( + serde_json::from_value::(invalid).is_err(), + "{state}" + ); + } + } + + #[test] + fn ticket_tag_operations_are_typed_workspace_routes() { + for (name, method, path) in [ + ( + "workspace_ticket_tag_list", + HttpMethod::Get, + "/api/w/{workspace_id}/ticket-tags", + ), + ( + "workspace_ticket_tag_mutate", + HttpMethod::Post, + "/api/w/{workspace_id}/ticket-tags", + ), + ( + "ticket_tags_set", + HttpMethod::Put, + "/api/w/{workspace_id}/tickets/{id}/tags", + ), + ] { + let operation = ServerApiMetadata::OPERATIONS + .iter() + .find(|operation| operation.operation_id == name) + .unwrap(); + assert_eq!(operation.method, method); + assert_eq!(operation.path, path); + } + let contract = canonical_openapi_document().unwrap().to_json().unwrap(); + let contract: serde_json::Value = serde_json::from_str(&contract).unwrap(); + for (path, method) in [ + ("/api/w/{workspace_id}/ticket-tags", "post"), + ("/api/w/{workspace_id}/tickets/{id}/tags", "put"), + ] { + let operation = &contract["paths"][path][method]; + assert!( + operation["responses"].get("409").is_some(), + "CAS conflict must be documented" + ); + assert!(operation["requestBody"]["required"].as_bool().unwrap()); + } + } + + #[cfg(feature = "typescript")] + #[test] + fn ticket_tag_typescript_references_are_registered() { + use ts_rs::TS; + let config = ts_rs::Config::default(); + assert!(TicketDetail::decl(&config).contains("tags: Array")); + assert!(TicketQueryItem::decl(&config).contains("tags: Array")); + let contract = canonical_openapi_document().unwrap().to_json().unwrap(); + let generated = + repository_openapi_typescript::generate_ticket_browser_typescript(&contract).unwrap(); + assert!(generated.contains("export type WorkspaceTag =")); + assert!(generated.contains("tags: Array")); + } + #[test] fn ticket_role_assignment_request_defaults_empty_bindings_and_requires_each_selected_connection() { @@ -11512,14 +12013,9 @@ mod tests { ); for (operation_id, method, path) in [ ( - "ticket_state_update", + "ticket_case_update", HttpMethod::Post, - "/api/w/{workspace_id}/tickets/{id}/state-update", - ), - ( - "ticket_complete", - HttpMethod::Post, - "/api/w/{workspace_id}/tickets/{id}/complete", + "/workspaces/{workspace_id}/tickets/{ticket_id}/case", ), ( "ticket_relation_query", @@ -12712,31 +13208,178 @@ mod tests { } #[test] - fn ticket_completion_wire_requires_cas_and_judgment_not_merge_evidence() { + fn ticket_case_wire_requires_cas_and_judgment_without_client_author() { let request = serde_json::json!({ - "operation_key": "investigation-done", + "operation_key": "question-answered", "expected_content_digest": "ticket:1", - "expected_state": "planning", - "reason": "Question answered in the thread" + "expected_state": "open", + "expected_closure_operation_key": null, + "action": {"action": "close", "reason": "completed"}, + "explanation": "Question answered in the thread" }); - let parsed: CompleteTicketRequest = serde_json::from_value(request.clone()).unwrap(); + let parsed: TicketCaseUpdateRequest = serde_json::from_value(request.clone()).unwrap(); assert!(parsed.references.is_empty()); + assert_eq!(parsed.expected_state, ticket::TicketState::Open); for field in [ "operation_key", "expected_content_digest", "expected_state", - "reason", + "expected_closure_operation_key", + "action", + "explanation", ] { let mut missing = request.clone(); missing.as_object_mut().unwrap().remove(field); assert!( - serde_json::from_value::(missing).is_err(), + serde_json::from_value::(missing).is_err(), "{field}" ); } - let mut old = request; - old["requirement_approval_event_id"] = serde_json::json!("not-a-judgment"); - assert!(serde_json::from_value::(old).is_err()); + for (field, value) in [ + ("author", serde_json::json!("untrusted-author")), + ( + "requirement_approval_event_id", + serde_json::json!("not-a-judgment"), + ), + ("expected_state", serde_json::json!("done")), + ] { + let mut rejected = request.clone(); + rejected[field] = value; + assert!( + serde_json::from_value::(rejected).is_err(), + "{field}" + ); + } + } + + #[test] + fn ticket_case_wire_preserves_actions_and_externally_tagged_other_reason() { + for action in [ + serde_json::json!({"action": "close", "reason": "not_planned"}), + serde_json::json!({"action": "reopen"}), + serde_json::json!({"action": "correct_closure", "reason": {"other": "Deferred by user"}}), + ] { + let request = serde_json::json!({ + "operation_key": "case-decision", + "expected_content_digest": "ticket:2", + "expected_state": "closed", + "expected_closure_operation_key": "previous-close", + "action": action, + "explanation": "Explicit user decision", + "references": [{"kind": "ticket", "target": "T-2"}] + }); + let parsed: TicketCaseUpdateRequest = serde_json::from_value(request.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), request); + } + } + + #[test] + fn ticket_assignment_tracking_wire_has_explicit_event_cas_without_actor_claims() { + for status in ["waiting", "inprogress", "done"] { + let input = serde_json::json!({"assignment_id":"delegation", "expected_last_event_id":"observed-event", "operation_key":"handling", "tracking":{"status":status, "waiting_reason":null, "progress":"Investigated", "next_action":null, "result":null}}); + let request: TicketAssignmentTrackingUpdateRequest = + serde_json::from_value(input.clone()).unwrap(); + assert_eq!(serde_json::to_value(request).unwrap(), input); + for field in [ + "assignment_id", + "expected_last_event_id", + "operation_key", + "tracking", + ] { + let mut missing = input.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + serde_json::from_value::(missing) + .is_err(), + "{field}" + ); + } + for field in [ + "author", + "role", + "source_context", + "runtime_id", + "worker_id", + ] { + let mut invalid = input.clone(); + invalid[field] = serde_json::json!("orchestrator"); + assert!( + serde_json::from_value::(invalid) + .is_err(), + "{field}" + ); + } + let mut invalid = input.clone(); + invalid["tracking"]["status"] = serde_json::json!("running"); + assert!( + serde_json::from_value::(invalid).is_err() + ); + } + } + + #[test] + fn ticket_query_accepts_only_case_states_and_current_attention_filters() { + let parsed: TicketQueryRequest = serde_json::from_value(serde_json::json!({ + "states": ["open", "closed"], "attention": ["blocked", "missing_evidence"] + })) + .unwrap(); + assert_eq!(parsed.states[0], ticket::TicketState::Open); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "in_progress", + "done", + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"states": [state]}) + ) + .is_err(), + "{state}" + ); + } + for attention in ["done_not_closed", "ready"] { + assert!( + serde_json::from_value::( + serde_json::json!({"attention": [attention]}) + ) + .is_err(), + "{attention}" + ); + } + } + + #[test] + fn ticket_case_route_replaces_all_legacy_case_judgment_bypasses() { + let operations = ServerApiMetadata::OPERATIONS; + for removed in [ + "ticket_complete", + "ticket_close", + "ticket_close_record", + "ticket_state_update", + "ticket_state_transition", + "ticket_state_change_add", + "ticket_state_field_set", + "ticket_workflow_state_set", + ] { + assert!( + !operations + .iter() + .any(|operation| operation.operation_id == removed), + "{removed}" + ); + } + let operation = operations + .iter() + .find(|operation| operation.operation_id == "ticket_case_update") + .unwrap(); + assert_eq!(operation.method, HttpMethod::Post); + assert_eq!( + operation.path, + "/workspaces/{workspace_id}/tickets/{ticket_id}/case" + ); } #[cfg(feature = "typescript")] diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 9dde1f3d8..9c7e0bfff 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -438,6 +438,34 @@ fn validate_ticket_browser_operations( paths: &Map, ) -> Result, GenerationError> { let operations = [ + OperationContract { + path: "/api/w/{workspace_id}/ticket-tags", + method: "get", + operation_id: "workspace_ticket_tag_list", + request: None, + successes: &[("200", "Array_of_WorkspaceTag")], + }, + OperationContract { + path: "/api/w/{workspace_id}/ticket-tags", + method: "post", + operation_id: "workspace_ticket_tag_mutate", + request: Some("WorkspaceTagMutation"), + successes: &[("200", "WorkspaceTag")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{id}/tags", + method: "put", + operation_id: "ticket_tags_set", + request: Some("TicketTagSetRequest"), + successes: &[("200", "Array_of_WorkspaceTag")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/query", + method: "post", + operation_id: "ticket_query", + request: Some("TicketQueryRequest"), + successes: &[("200", "TicketQueryResponse")], + }, OperationContract { path: "/api/w/{workspace_id}/tickets", method: "get", @@ -481,11 +509,74 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketDetail")], }, OperationContract { - path: "/api/w/{workspace_id}/tickets/{id}/state", + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case", method: "post", - operation_id: "ticket_state_transition", - request: Some("BrowserTransitionTicketStateRequest"), - successes: &[("200", "TicketDetail")], + operation_id: "ticket_case_update", + request: Some("TicketCaseUpdateRequest"), + successes: &[("200", "TicketSummary")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", + method: "get", + operation_id: "ticket_assignments_page", + request: None, + successes: &[("200", "TicketAssignmentsPage")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", + method: "get", + operation_id: "ticket_assignment_diagnostics", + request: None, + successes: &[("200", "TicketAssignmentDiagnosticsPage")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer", + method: "post", + operation_id: "ticket_assignment_answer", + request: Some("TicketAssignmentAnswer"), + successes: &[("200", "TicketAssignmentAnswerReceipt")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments", + method: "get", + operation_id: "ticket_assignments_page_browser", + request: None, + successes: &[("200", "TicketAssignmentsPage")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics", + method: "get", + operation_id: "ticket_assignment_diagnostics_browser", + request: None, + successes: &[("200", "TicketAssignmentDiagnosticsPage")], + }, + OperationContract { + path: "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer", + method: "post", + operation_id: "ticket_assignment_answer_browser", + request: Some("TicketAssignmentAnswer"), + successes: &[("200", "TicketAssignmentAnswerReceipt")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", + method: "get", + operation_id: "ticket_assignment_tracking_get", + request: None, + successes: &[("200", "Array_of_TicketAssignmentTrackingProjection")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking", + method: "post", + operation_id: "ticket_assignment_tracking_update", + request: Some("TicketAssignmentTrackingUpdate"), + successes: &[("200", "TicketAssignmentTrackingProjection")], + }, + OperationContract { + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history", + method: "get", + operation_id: "ticket_assignment_tracking_history", + request: None, + successes: &[("200", "TicketAssignmentTrackingHistoryPage")], }, OperationContract { path: "/api/w/{workspace_id}/tickets/{id}/ready", @@ -508,13 +599,6 @@ fn validate_ticket_browser_operations( request: Some("BrowserQueueTicketRequest"), successes: &[("200", "TicketQueueOutcome")], }, - OperationContract { - path: "/api/w/{workspace_id}/tickets/{id}/close", - method: "post", - operation_id: "ticket_close", - request: Some("BrowserCloseTicketRequest"), - successes: &[("200", "TicketDetail")], - }, OperationContract { path: "/api/w/{workspace_id}/objectives", method: "get", @@ -1906,6 +1990,54 @@ mod tests { assert!(!detail.contains(" target:")); } + #[test] + fn ticket_browser_generator_exposes_case_judgment_and_closure_projections() { + let openapi = crate::canonical_openapi_document() + .unwrap() + .to_json() + .unwrap(); + let output = generate_ticket_browser_typescript(&openapi).unwrap(); + assert!(output.contains("export type BrowserTicketState = \"open\" | \"closed\";")); + let request = output + .lines() + .find(|line| line.starts_with("export type TicketCaseUpdateRequest =")) + .unwrap(); + for field in [ + "expected_content_digest: string", + "expected_state: TicketState", + "expected_closure_operation_key: string | null", + "operation_key: string", + "action: TicketCaseAction", + "explanation: string", + ] { + assert!(request.contains(field), "missing {field}: {request}"); + } + assert!(!request.contains("author")); + assert!(output.contains("export type TicketClosureReason = \"completed\" | \"not_planned\" | \"duplicate\" | \"unknown\" | { other: string; };")); + let detail = output + .lines() + .find(|line| line.starts_with("export type TicketDetail =")) + .unwrap(); + for field in [ + "state: BrowserTicketState", + "closure_operation_key?: string | null", + "current_closure?: TicketClosureJudgment | null", + "closure_history: Array", + "legacy_handoff?: TicketLegacyWorkflowSnapshot | null", + ] { + assert!(detail.contains(field), "missing {field}: {detail}"); + } + for removed in [ + "BrowserTransitionTicketStateRequest", + "BrowserCloseTicketRequest", + "CompleteTicketRequest", + "BrowserTicketWorkflowState", + "done_not_closed", + ] { + assert!(!output.contains(removed), "{removed}"); + } + } + #[test] fn checked_in_ticket_browser_typescript_is_current() { assert_eq!( diff --git a/crates/session-store/src/history.rs b/crates/session-store/src/history.rs index 4db39dd3d..6f03e1466 100644 --- a/crates/session-store/src/history.rs +++ b/crates/session-store/src/history.rs @@ -67,6 +67,8 @@ pub enum LoggedSessionHistoryOrigin { ToolOutput { worker: LoggedWorkerSubject, }, + /// Host-produced opening context summary; not auto-read or TaskStore material. + CompactionSummary, DerivedSummary, LegacyUnknown, } diff --git a/crates/session-store/src/public_snapshot.rs b/crates/session-store/src/public_snapshot.rs index 6d1c1a4d7..d8e075045 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -1888,7 +1888,8 @@ fn provenance(origin: &LoggedSessionHistoryOrigin) -> SessionEntryProvenance { } LoggedSessionHistoryOrigin::ModelOutput { .. } => SessionEntryProvenance::ModelOutput, LoggedSessionHistoryOrigin::ToolOutput { .. } => SessionEntryProvenance::ToolOutput, - LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary, + LoggedSessionHistoryOrigin::CompactionSummary + | LoggedSessionHistoryOrigin::DerivedSummary => SessionEntryProvenance::DerivedSummary, LoggedSessionHistoryOrigin::LegacyUnknown => SessionEntryProvenance::LegacyUnknown, } } @@ -2559,6 +2560,12 @@ mod tests { user_message(10), assistant_message(10, "final"), synthetic_compaction_input, + history_message( + "host-summary", + LoggedRole::System, + "[Compacted context summary]\n\nprivate inherited context", + LoggedSessionHistoryOrigin::CompactionSummary, + ), ], None, Some(SegmentOrigin { diff --git a/crates/ticket/README.md b/crates/ticket/README.md index f9f9ec978..3b58ee68c 100644 --- a/crates/ticket/README.md +++ b/crates/ticket/README.md @@ -6,6 +6,6 @@ Does not search Repository documents or handle Git integration. ## Main entry points - [`TicketBackend`], [`SqliteTicketBackend`]: operation boundary and persistence -- [`TicketBackend::complete`], [`TicketBackend::update_state`]: completion decisions and state updates +- [`TicketBackend::update_case`]: reasoned close, reopen, and closure correction - [`ticket_content_digest`]: requirement-content comparison condition - [`tool`]: Worker-facing operation definitions diff --git a/crates/ticket/src/assignment_answer.rs b/crates/ticket/src/assignment_answer.rs new file mode 100644 index 000000000..f321c9975 --- /dev/null +++ b/crates/ticket/src/assignment_answer.rs @@ -0,0 +1,122 @@ +//! Continuing input is authored against an observed current delegation and handling +//! event, never a new Assign. Only open cases with waiting/inprogress handling admit new +//! answers; done requires explicit reissue. Answers append input without changing handling. +//! The host fences authenticated source/current identity and saves input/receipt atomically, +//! then notifies delivery after commit. Exact replay keeps its original key and full input; +//! its saved receipt does not attest delivery, model consideration, or execution start. +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentAnswer { + pub assignment_id: String, + pub expected_last_event_id: String, + pub operation_key: String, + pub body: String, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentAnswerReceipt { + pub assignment_id: String, + pub input_event_id: String, + #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] + pub event_index: u64, +} + +/// Host owns the IMMEDIATE transaction and authenticates/fences its source before +/// calling, including on replay. Reuse Ticket's existing immutable operation +/// receipts with a tagged input; these are not handling events or another ledger. +pub fn sqlite_answer_ticket_assignment_in_transaction( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + answer: &TicketAssignmentAnswer, + source_context: &serde_json::Value, + actor: &str, +) -> Result { + for value in [ + &answer.assignment_id, + &answer.expected_last_event_id, + &answer.operation_key, + actor, + ] { + if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) { + return Err(TicketError::Conflict( + "Answer requires bounded nonempty identities".into(), + )); + } + } + if answer.body.trim().is_empty() || answer.body.len() > 16384 || answer.body.contains('\0') { + return Err(TicketError::Conflict( + "Answer body must be nonempty, at most 16384 bytes and without NUL".into(), + )); + } + let input = + serde_json::to_string(&serde_json::json!({"kind":"assignment_answer", "answer":answer})) + .map_err(sqlite_err)?; + let source = serde_json::to_string( + &serde_json::json!({"kind":"assignment_answer", "source":source_context, "actor":actor}), + ) + .map_err(sqlite_err)?; + let receipt: Option<(String,String,String,String)> = conn.query_row( + "SELECT ticket_id,input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2", + params![workspace_id, answer.operation_key], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional().map_err(sqlite_err)?; + if let Some((saved_ticket, saved_input, saved_source, saved_result)) = receipt { + if saved_ticket != ticket_id || saved_input != input || saved_source != source { + return Err(TicketError::OperationFingerprintMismatch { + operation_key: answer.operation_key.clone(), + }); + } + return serde_json::from_str(&saved_result).map_err(sqlite_err); + } + let current = + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &answer.assignment_id)? + .ok_or_else(|| { + TicketError::Conflict("Answer assignment does not exist in this Ticket".into()) + })?; + if !current.current + || current.last_event_id.as_deref() != Some(&answer.expected_last_event_id) + || !current.tracking.as_ref().is_some_and(|tracking| { + matches!( + tracking.status, + TicketAssignmentTrackingStatus::Waiting + | TicketAssignmentTrackingStatus::Inprogress + ) + }) + { + return Err(TicketError::Conflict("Answer observed assignment or handling event is stale; read current responsibility before authoring".into())); + } + let open: bool = conn.query_row("SELECT workflow_state='open' FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", params![workspace_id,ticket_id], |r| r.get(0)).map_err(sqlite_err)?; + if !open { + return Err(TicketError::Conflict( + "A closed Ticket does not admit continuing work input".into(), + )); + } + let index: i64 = conn.query_row("SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace_id,ticket_id],|r| r.get(0)).map_err(sqlite_err)?; + let at = now_utc(); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,heading,body) VALUES(?1,?2,?3,'assignment_answer',?4,?5,'Assignment answer',?6)", params![workspace_id,ticket_id,index,actor,at,answer.body]).map_err(sqlite_err)?; + for (key, value) in [ + ("orchestrator_assignment_id", answer.assignment_id.as_str()), + ( + "expected_last_event_id", + answer.expected_last_event_id.as_str(), + ), + ("operation_key", answer.operation_key.as_str()), + ("authenticated_source", source.as_str()), + ] { + conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) VALUES(?1,?2,?3,?4,?5)",params![workspace_id,ticket_id,index,key,value]).map_err(sqlite_err)?; + } + conn.execute( + "UPDATE typed_tickets SET updated_at=?3 WHERE workspace_id=?1 AND ticket_id=?2", + params![workspace_id, ticket_id, at], + ) + .map_err(sqlite_err)?; + let result = TicketAssignmentAnswerReceipt { + assignment_id: answer.assignment_id.clone(), + input_event_id: format!("ticket-event:{index}"), + event_index: index as u64, + }; + let result_json = serde_json::to_string(&result).map_err(sqlite_err)?; + conn.execute("INSERT INTO typed_ticket_assignment_tracking_operations(workspace_id,operation_key,ticket_id,input_json,source_context_json,result_json) VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,answer.operation_key,ticket_id,input,source,result_json]).map_err(sqlite_err)?; + Ok(result) +} diff --git a/crates/ticket/src/assignment_schema.sql b/crates/ticket/src/assignment_schema.sql new file mode 100644 index 000000000..8dadb7846 --- /dev/null +++ b/crates/ticket/src/assignment_schema.sql @@ -0,0 +1,105 @@ +-- Ticket owns assignment identities, current pointers, audit and operation receipts. +CREATE TABLE IF NOT EXISTS ticket_assignment_operations ( + 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, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + assignment_id TEXT, + expected_assignment_id TEXT, + created_at TEXT NOT NULL, + request_fingerprint TEXT, + claim_state TEXT NOT NULL DEFAULT 'committed' + CHECK(claim_state IN ('pending', 'failed', 'committed')), + failure_reason TEXT, + binding_recovery_json TEXT, + PRIMARY KEY (workspace_id, operation_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK(claim_state = 'committed' OR assignment_id IS NULL), + CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL)) + ); +CREATE TABLE IF NOT EXISTS ticket_current_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + assignment_id TEXT NOT NULL, + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + updated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, role, assignment_id), + UNIQUE (workspace_id, assignment_id), + FOREIGN KEY (workspace_id, ticket_id, role, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, ticket_id, role, assignment_id) + ON DELETE CASCADE, + FOREIGN KEY (workspace_id, ticket_id) + REFERENCES typed_tickets(workspace_id, ticket_id) + ON DELETE CASCADE, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); +CREATE TABLE IF NOT EXISTS ticket_worker_assignment_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + 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, + operation_id TEXT, + reason TEXT, + PRIMARY KEY (workspace_id, event_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE + ); +CREATE TABLE IF NOT EXISTS ticket_worker_assignments ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'worker' + CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), + principal_kind TEXT NOT NULL DEFAULT 'worker' + CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), + principal_id TEXT, + runtime_id TEXT, + worker_id TEXT, + 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, role, assignment_id), + UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id), + FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, + CHECK( + (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) + OR + (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) + ) + ); +CREATE INDEX IF NOT EXISTS idx_ticket_assignment_operations_ticket + ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignment_events_ticket + ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_principal + ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC); +CREATE INDEX IF NOT EXISTS idx_ticket_worker_assignments_ticket + ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC); +CREATE INDEX IF NOT EXISTS ticket_current_principal_idx + ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id); +CREATE UNIQUE INDEX IF NOT EXISTS ticket_current_singleton_role_idx + ON ticket_current_worker_assignments(workspace_id, ticket_id, role) + WHERE role IN ('orchestrator', 'worker'); diff --git a/crates/ticket/src/assignment_tracking.rs b/crates/ticket/src/assignment_tracking.rs new file mode 100644 index 000000000..a4ebf2660 --- /dev/null +++ b/crates/ticket/src/assignment_tracking.rs @@ -0,0 +1,964 @@ +//! Ticket-owned delegation identity and self-reported handling, not Runtime execution, +//! grants, delivery acknowledgement, or MR evidence. Each explicit Assign/reissue has a +//! new immutable identity, even for the same principal; Worker replacement is not Assign. +//! Assign commits the current pointer, initial waiting, audit, and receipt together. +//! +//! Handling has no mandatory progression: waiting-to-done is valid without MR approval. +//! Done does not close the case, release work, terminate a Worker, or clean up resources. +//! Close/reopen does not change handling; current responsibility can report after close +//! without restoring execution grants. Retained unknown handling is not inferred waiting/done. +//! +//! The host authenticates the current workspace-agent Worker and its actual Runtime +//! binding, including retries, inside the same write transaction as receipt comparison, +//! current-assignment/event CAS, audit, and result. Never split that transaction around +//! awaits. A replayed receipt is not a fresh event observation: reread before a new update, +//! and reconcile stale or unknown outcomes instead of silently issuing a new operation key. +use super::*; + +/// Used by the Workspace host to compose its stricter principal constraints. +/// Historical physical names remain to preserve immutable IDs and receipts. +pub const TICKET_ASSIGNMENT_SCHEMA_SQL: &str = include_str!("assignment_schema.sql"); + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "lowercase")] +pub enum TicketAssignmentTrackingStatus { + Waiting, + Inprogress, + Done, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTracking { + pub status: TicketAssignmentTrackingStatus, + pub waiting_reason: Option, + pub progress: Option, + pub next_action: Option, + pub result: Option, +} + +/// Replaces the complete handling payload against this delegation's observed event, +/// not status alone. Exact retry compares full input and authenticated sender/binding. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingUpdate { + pub assignment_id: String, + pub expected_last_event_id: String, + pub operation_key: String, + pub tracking: TicketAssignmentTracking, +} + +/// Backend-owned authenticated context, never decoded from model input. Agent +/// principal remains stable across Worker exchange; exact replay binds to the +/// authenticated concrete sender and its binding, not merely a claimed role. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct TicketAssignmentTrackingSourceContext { + pub actor: String, + pub runtime_id: String, + pub worker_id: String, + pub agent_key: String, + pub authentication_binding_id: String, +} + +/// Enrichment is separate from the established assignment record. `None` +/// tracking means a retained historical relationship with insufficient delegation +/// evidence, not waiting/done. Removal/replacement does not alter last tracking. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentTrackingProjection { + pub workspace_id: String, + pub ticket_id: String, + pub assignment_id: String, + pub role: String, + pub principal: TicketAssignmentPrincipalSnapshot, + pub assigned_by: String, + pub assigned_at: String, + pub release: Option, + pub current: bool, + pub tracking: Option, + /// Tracking event, or the existing immutable Assign audit observation for a + /// retained current relationship with no tracking yet. This baseline does + /// not imply a handling status, delegation intent, or tracking timestamp. + pub last_event_id: Option, + pub updated_at: Option, + pub migration_diagnostic: Option, +} + +/// Immutable principal as stored at Assign, not live Worker availability. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TicketAssignmentPrincipalSnapshot { + User { + account_id: String, + }, + Worker { + runtime_id: String, + worker_id: String, + }, + WorkspaceAgent { + agent_key: String, + }, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(rename_all = "snake_case")] +pub enum TicketAssignmentReleaseAction { + Unassigned, + Replaced, +} + +/// Release is an independent immutable assignment audit event. Last tracking +/// remains unchanged, including unknown legacy tracking; it is never made done. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentRelease { + pub action: TicketAssignmentReleaseAction, + pub event_id: String, + pub operation_key: Option, + pub actor: String, + pub at: String, + pub reason: Option, + pub replacement_assignment_id: Option, + pub last_tracking: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentTrackingHistoryEntry { + pub event_id: String, + pub operation_key: String, + pub tracking: TicketAssignmentTracking, + pub source_context: serde_json::Value, + pub at: String, +} + +/// Bounded append-order page. Cursor is an immutable event identity, not a +/// lexical event-ID comparison or a transferable offset into another assignment. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentTrackingHistoryPage { + pub items: Vec, + pub next_event_id: Option, +} + +fn valid_identity(value: &str) -> bool { + !value.trim().is_empty() && value.len() <= 1024 && !value.chars().any(char::is_control) +} + +fn validate_update( + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, +) -> Result<()> { + if [ + &update.assignment_id, + &update.expected_last_event_id, + &update.operation_key, + &source.actor, + &source.runtime_id, + &source.worker_id, + &source.authentication_binding_id, + ] + .into_iter() + .any(|value| !valid_identity(value)) + || source.agent_key != "workspace-orchestrator" + { + return Err(TicketError::Conflict( + "tracking requires bounded identities and authenticated workspace-orchestrator source" + .into(), + )); + } + for text in [ + &update.tracking.waiting_reason, + &update.tracking.progress, + &update.tracking.next_action, + &update.tracking.result, + ] + .into_iter() + .flatten() + { + if text.len() > 16 * 1024 || text.contains('\0') { + return Err(TicketError::Conflict( + "tracking fields must be at most 16384 bytes without NUL".into(), + )); + } + } + Ok(()) +} + +/// Reads responsibility independently of case state and operational work release. +pub fn sqlite_ticket_assignment_tracking( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, +) -> Result> { + let row = conn.query_row("SELECT a.tracking_json, a.last_tracking_event_id, a.tracking_updated_at, a.tracking_migration_diagnostic, + EXISTS(SELECT 1 FROM ticket_current_worker_assignments c WHERE c.workspace_id=a.workspace_id AND c.assignment_id=a.assignment_id), + a.role, a.principal_kind, a.principal_id, a.runtime_id, a.worker_id, a.assigned_by, a.assigned_at + FROM ticket_worker_assignments a WHERE a.workspace_id=?1 AND a.ticket_id=?2 AND a.assignment_id=?3 AND a.role='orchestrator'", + params![workspace_id,ticket_id,assignment_id], |r| Ok((r.get::<_,Option>(0)?,r.get::<_,Option>(1)?,r.get::<_,Option>(2)?,r.get::<_,Option>(3)?,r.get::<_,bool>(4)?, r.get::<_,String>(5)?, r.get::<_,String>(6)?,r.get::<_,Option>(7)?,r.get::<_,Option>(8)?,r.get::<_,Option>(9)?,r.get::<_,String>(10)?,r.get::<_,String>(11)?))) + .optional().map_err(sqlite_err)?; + let Some(( + json, + last_event_id, + updated_at, + migration_diagnostic, + current, + role, + kind, + principal_id, + runtime_id, + worker_id, + assigned_by, + assigned_at, + )) = row + else { + return Ok(None); + }; + let tracking: Option = json + .map(|value| serde_json::from_str(&value).map_err(sqlite_err)) + .transpose()?; + // Legacy migration may retain current responsibility without evidence of + // handling status. Its real Assign audit is still an immutable observation + // against which the first explicit self-report can CAS, even after close. + // Never manufacture a tracking event/status or repair a missing audit. Once + // tracking exists, only its event identity is a valid observation. + let last_event_id = if current && tracking.is_none() && last_event_id.is_none() { + conn.query_row( + "SELECT e.event_id FROM ticket_worker_assignment_events e + WHERE e.workspace_id=?1 AND e.ticket_id=?2 AND e.assignment_id=?3 + AND e.role='orchestrator' AND e.action IN ('assigned','reassigned') + AND NOT EXISTS (SELECT 1 FROM typed_ticket_assignment_tracking_history h + WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.assignment_id=?3) + ORDER BY e.rowid LIMIT 1", + params![workspace_id, ticket_id, assignment_id], + |row| row.get::<_, String>(0), + ) + .optional() + .map_err(sqlite_err)? + } else { + last_event_id + }; + let missing = + || TicketError::Conflict("stored assignment principal identity is incomplete".into()); + let principal = match kind.as_str() { + "workspace_agent" => TicketAssignmentPrincipalSnapshot::WorkspaceAgent { + agent_key: principal_id.ok_or_else(missing)?, + }, + "user" => TicketAssignmentPrincipalSnapshot::User { + account_id: principal_id.ok_or_else(missing)?, + }, + "worker" => TicketAssignmentPrincipalSnapshot::Worker { + runtime_id: runtime_id.ok_or_else(missing)?, + worker_id: worker_id.ok_or_else(missing)?, + }, + _ => return Err(missing()), + }; + let release = conn.query_row("SELECT action,event_id,operation_id,actor,created_at,reason,assignment_id FROM ticket_worker_assignment_events + WHERE workspace_id=?1 AND ticket_id=?2 AND ((action='unassigned' AND (assignment_id=?3 OR previous_assignment_id=?3)) OR (action='reassigned' AND previous_assignment_id=?3)) ORDER BY rowid DESC LIMIT 1", + params![workspace_id,ticket_id,assignment_id], |r| Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,Option>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,Option>(5)?,r.get::<_,Option>(6)?))).optional().map_err(sqlite_err)? + .map(|(action,event_id,operation_key,actor,at,reason,replacement)| TicketAssignmentRelease { + action: if action=="unassigned" {TicketAssignmentReleaseAction::Unassigned} else {TicketAssignmentReleaseAction::Replaced}, + event_id,operation_key,actor,at,reason,replacement_assignment_id: if action=="reassigned" {replacement} else {None}, last_tracking: tracking.clone(), + }); + Ok(Some(TicketAssignmentTrackingProjection { + workspace_id: workspace_id.into(), + ticket_id: ticket_id.into(), + assignment_id: assignment_id.into(), + role, + principal, + assigned_by, + assigned_at, + release, + current, + tracking, + last_event_id, + updated_at, + migration_diagnostic, + })) +} + +pub fn sqlite_current_ticket_assignment_tracking( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, +) -> Result> { + let ids = conn.prepare("SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' ORDER BY assignment_id") + .map_err(sqlite_err)?.query_map(params![workspace_id,ticket_id],|r|r.get::<_,String>(0)).map_err(sqlite_err)? + .collect::,_>>().map_err(sqlite_err)?; + ids.into_iter() + .map(|id| { + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &id)? + .ok_or_else(|| TicketError::Conflict("current assignment row is missing".into())) + }) + .collect() +} + +pub fn sqlite_ticket_assignment_tracking_history( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, +) -> Result> { + let mut stmt=conn.prepare("SELECT h.event_id,h.operation_key,h.tracking_json,h.source_context_json,h.at FROM typed_ticket_assignment_tracking_history h + WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.assignment_id=?3 ORDER BY h.rowid").map_err(sqlite_err)?; + let rows = stmt + .query_map(params![workspace_id, ticket_id, assignment_id], |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + )) + }) + .map_err(sqlite_err)?; + rows.map(|r| { + let (event_id, operation_key, json, source, at) = r.map_err(sqlite_err)?; + Ok(TicketAssignmentTrackingHistoryEntry { + event_id, + operation_key, + tracking: serde_json::from_str(&json).map_err(sqlite_err)?, + source_context: serde_json::from_str(&source).map_err(sqlite_err)?, + at, + }) + }) + .collect() +} + +/// DB-bounded history pagination. Immutable append-only rows provide a stable +/// cursor position; the scope index also orders its implicit rowids. No caller +/// needs to load, deserialize or truncate the lifetime assignment history. +pub fn sqlite_ticket_assignment_tracking_history_page( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_event_id: Option<&str>, + limit: usize, +) -> Result { + if !(1..=100).contains(&limit) { + return Err(TicketError::Conflict( + "tracking history limit must be 1..100".into(), + )); + } + let exists:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND role='orchestrator')",params![workspace_id,ticket_id,assignment_id],|r|r.get(0)).map_err(sqlite_err)?; + if !exists { + return Err(TicketError::Conflict( + "tracking assignment is not in this Workspace/Ticket".into(), + )); + } + let after_rowid:Option=after_event_id.map(|event| conn.query_row("SELECT rowid FROM typed_ticket_assignment_tracking_history WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 AND event_id=?4",params![workspace_id,ticket_id,assignment_id,event],|r|r.get::<_,i64>(0)).optional().map_err(sqlite_err)?.ok_or_else(||TicketError::Conflict("tracking history cursor is not in this exact Workspace/Ticket/assignment".into()))).transpose()?; + let after_clause = if after_rowid.is_some() { + "AND h.rowid>?4" + } else { + "AND ?4 IS NULL" + }; + let sql = format!( + "SELECT h.event_id,h.operation_key,h.tracking_json,h.source_context_json,h.at FROM typed_ticket_assignment_tracking_history h WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.assignment_id=?3 {after_clause} ORDER BY h.rowid LIMIT ?5" + ); + let mut stmt = conn.prepare(&sql).map_err(sqlite_err)?; + let rows = stmt + .query_map( + params![ + workspace_id, + ticket_id, + assignment_id, + after_rowid, + (limit + 1) as i64 + ], + |r| { + Ok(( + r.get::<_, String>(0)?, + r.get::<_, String>(1)?, + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + )) + }, + ) + .map_err(sqlite_err)?; + let mut items = rows + .map(|r| { + let (event_id, operation_key, json, source, at) = r.map_err(sqlite_err)?; + Ok(TicketAssignmentTrackingHistoryEntry { + event_id, + operation_key, + tracking: serde_json::from_str(&json).map_err(sqlite_err)?, + source_context: serde_json::from_str(&source).map_err(sqlite_err)?, + at, + }) + }) + .collect::>>()?; + let has_more = items.len() > limit; + items.truncate(limit); + let next_event_id = if has_more { + items.last().map(|entry| entry.event_id.clone()) + } else { + None + }; + Ok(TicketAssignmentTrackingHistoryPage { + items, + next_event_id, + }) +} + +/// Host owns BEGIN IMMEDIATE/commit. Receipt lookup precedes current/CAS checks; +/// exact replay returns the immutable operation result even after reassignment. +/// Case closed and work-release records are deliberately absent from this guard. +pub fn sqlite_update_ticket_assignment_tracking_in_transaction( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, +) -> Result { + if conn.is_autocommit() { + return Err(TicketError::Conflict( + "tracking update requires a host write transaction".into(), + )); + } + validate_update(update, source)?; + let input_json = serde_json::to_string(&(ticket_id, update)).map_err(sqlite_err)?; + let source_json = serde_json::to_string(source).map_err(sqlite_err)?; + let receipt=conn.query_row("SELECT input_json,source_context_json,result_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2",params![workspace_id,update.operation_key],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?))).optional().map_err(sqlite_err)?; + if let Some((input, context, result)) = receipt { + if input != input_json || context != source_json { + return Err(TicketError::Conflict( + "tracking operation key already used for different input or authenticated source" + .into(), + )); + } + return serde_json::from_str(&result).map_err(sqlite_err); + } + let current = + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &update.assignment_id)? + .ok_or_else(|| { + TicketError::Conflict( + "tracking assignment not found in this Workspace/Ticket".into(), + ) + })?; + if !current.current + || current.last_event_id.as_deref() != Some(update.expected_last_event_id.as_str()) + { + return Err(TicketError::Conflict( + "tracking assignment is removed/replaced or last event changed".into(), + )); + } + let principal_matches:bool=conn.query_row("SELECT principal_kind='workspace_agent' AND principal_id=?3 FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,source.agent_key],|r|r.get(0)).map_err(sqlite_err)?; + if !principal_matches { + return Err(TicketError::Conflict( + "authenticated agent does not own this assignment".into(), + )); + } + let event_id=format!("tracking-{}",allocate_record_id(unix_epoch_millis_now().unwrap_or(0), |candidate| conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_history WHERE workspace_id=?1 AND event_id=?2)",params![workspace_id,format!("tracking-{candidate}")],|r|r.get::<_,bool>(0)).unwrap_or(true)).map_err(sqlite_err)?); + let at = Utc::now().to_rfc3339(); + let tracking_json = serde_json::to_string(&update.tracking).map_err(sqlite_err)?; + conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?3,last_tracking_event_id=?4,tracking_updated_at=?5 WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,update.assignment_id,tracking_json,event_id,at]).map_err(sqlite_err)?; + conn.execute( + "INSERT INTO typed_ticket_assignment_tracking_history VALUES (?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + workspace_id, + ticket_id, + update.assignment_id, + event_id, + update.operation_key, + tracking_json, + source_json, + at + ], + ) + .map_err(sqlite_err)?; + let result = + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, &update.assignment_id)? + .ok_or_else(|| TicketError::Conflict("tracking assignment disappeared".into()))?; + conn.execute( + "INSERT INTO typed_ticket_assignment_tracking_operations VALUES (?1,?2,?3,?4,?5,?6)", + params![ + workspace_id, + update.operation_key, + ticket_id, + input_json, + source_json, + serde_json::to_string(&result).map_err(sqlite_err)? + ], + ) + .map_err(sqlite_err)?; + Ok(result) +} + +pub(crate) fn install_assignment_core(conn: &Connection) -> Result<()> { + // Standalone Ticket stores do not own Workspace records. The Workspace host + // composes the unmodified SQL and adds its principal/resource constraints. + let sql = if conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE name='workspaces')", + [], + |r| r.get::<_, bool>(0), + ) + .map_err(sqlite_err)? + { + TICKET_ASSIGNMENT_SCHEMA_SQL.to_owned() + } else { + TICKET_ASSIGNMENT_SCHEMA_SQL.replace("FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE,", "").replace(",\n FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE\n", "\n") + }; + conn.execute_batch(&sql).map_err(sqlite_err) +} + +pub(crate) fn migrate_assignment_tracking(conn: &Connection) -> Result<()> { + install_assignment_core(conn)?; + conn.execute_batch(include_str!("assignment_tracking_schema.sql")) + .map_err(sqlite_err)?; + reconcile_legacy_tracking(conn) +} + +fn reconcile_legacy_tracking(conn: &Connection) -> Result<()> { + let rows=conn.prepare("SELECT a.workspace_id,a.ticket_id,a.assignment_id,s.snapshot_json, + EXISTS(SELECT 1 FROM ticket_current_worker_assignments c WHERE c.workspace_id=a.workspace_id AND c.assignment_id=a.assignment_id) + FROM ticket_worker_assignments a LEFT JOIN typed_ticket_legacy_workflow_snapshot s ON s.workspace_id=a.workspace_id AND s.ticket_id=a.ticket_id WHERE a.role='orchestrator'").map_err(sqlite_err)? + .query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,Option>(3)?,r.get::<_,bool>(4)?))).map_err(sqlite_err)? + .collect::,_>>().map_err(sqlite_err)?; + for (workspace, ticket, assignment, snapshot, current) in rows { + let original = snapshot + .as_deref() + .map(serde_json::from_str::) + .transpose() + .map_err(sqlite_err)?; + // Read the latest routing intention, not just the latest matching + // fence: a newer mismatched Queue must not revive an older delegation. + let queue=conn.query_row("SELECT e.event_index,e.at,(e.state_field='routing' AND e.reason='queued'),f.value FROM typed_ticket_events e LEFT JOIN typed_ticket_event_attributes f ON f.workspace_id=e.workspace_id AND f.ticket_id=e.ticket_id AND f.event_index=e.event_index AND f.key='orchestrator_assignment_id' + WHERE e.workspace_id=?1 AND e.ticket_id=?2 AND (e.to_state='queued' OR (e.state_field='routing' AND e.reason='queued')) ORDER BY e.event_index DESC LIMIT 1",params![workspace,ticket],|r|Ok((r.get::<_,i64>(0)?,r.get::<_,Option>(1)?,r.get::<_,Option>(2)?.unwrap_or(false),r.get::<_,Option>(3)?))).optional().map_err(sqlite_err)?; + let original_active = original + .as_ref() + .is_some_and(|s| matches!(s.workflow_state.as_str(), "queued" | "inprogress")); + if let Some((index, at, typed_queue, _)) = queue.filter(|(_, _, typed_queue, fence)| { + current + && (original_active || *typed_queue) + && fence.as_deref() == Some(assignment.as_str()) + }) { + // A case judgment is earlier only when its immutable operation key + // identifies a case event before Queue. Missing correspondence is + // ambiguous; neither timestamp order nor Worker state repairs it. + let later_case:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=?1 AND h.ticket_id=?2 AND h.request_fingerprint!='migration-v10' + AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes operation JOIN typed_ticket_events e ON e.workspace_id=operation.workspace_id AND e.ticket_id=operation.ticket_id AND e.event_index=operation.event_index + WHERE operation.workspace_id=h.workspace_id AND operation.ticket_id=h.ticket_id AND operation.key='operation_key' AND operation.value=h.operation_key AND e.state_field='case' AND e.event_index?3 AND (state_field='case' OR to_state IN ('planning','ready','done','closed') OR (to_state='open' AND COALESCE(state_field,'')!='routing')))",params![workspace,ticket,index],|r|r.get(0)).map_err(sqlite_err)?; + if !later_case && !superseded { + let status = if !typed_queue + && original + .as_ref() + .is_some_and(|s| s.workflow_state == "inprogress") + { + TicketAssignmentTrackingStatus::Inprogress + } else { + TicketAssignmentTrackingStatus::Waiting + }; + let tracking = TicketAssignmentTracking { + status, + waiting_reason: Some( + "Migrated fenced legacy delegation; Orchestrator confirmation pending" + .into(), + ), + progress: None, + next_action: None, + result: None, + }; + let event_id = format!("migration-tracking:{assignment}"); + let at = at.unwrap_or_else(|| Utc::now().to_rfc3339()); + let json = serde_json::to_string(&tracking).map_err(sqlite_err)?; + conn.execute("UPDATE ticket_worker_assignments SET tracking_json=?3,last_tracking_event_id=?4,tracking_updated_at=?5,tracking_migration_diagnostic=?6 WHERE workspace_id=?1 AND assignment_id=?2",params![workspace,assignment,json,event_id,at,format!("Retained original workflow snapshot and latest Queue event {index} (typed routing={typed_queue}) fenced to this current assignment")]).map_err(sqlite_err)?; + conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES (?1,?2,?3,?4,?4,?5,'{\"kind\":\"migration\"}',?6)",params![workspace,ticket,assignment,event_id,json,at]).map_err(sqlite_err)?; + continue; + } + } + conn.execute("UPDATE ticket_worker_assignments SET tracking_migration_diagnostic='Historical assignment retained without tracking: absent/ambiguous or superseded original workflow and exact Queue fence; no delegation or done inferred' WHERE workspace_id=?1 AND assignment_id=?2",params![workspace,assignment]).map_err(sqlite_err)?; + } + Ok(()) +} + +pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()> { + // Object names alone would accept same-named no-op triggers after drift. + let canonical = Connection::open_in_memory().map_err(sqlite_err)?; + canonical + .execute_batch(TICKET_ASSIGNMENT_SCHEMA_SQL) + .map_err(sqlite_err)?; + canonical + .execute_batch(include_str!("assignment_tracking_schema.sql")) + .map_err(sqlite_err)?; + let normalize = |sql: String| { + // SQL keyword/format differences are harmless; literal case and spaces + // are not (for example changing the principal or role in a guard). + let mut result = String::new(); + let mut quoted = false; + let mut chars = sql.chars().peekable(); + while let Some(ch) = chars.next() { + if ch == '\'' { + result.push(ch); + if quoted && chars.peek() == Some(&'\'') { + result.push(chars.next().unwrap()); + } else { + quoted = !quoted; + } + } else if quoted { + result.push(ch); + } else if !ch.is_whitespace() { + result.push(ch.to_ascii_lowercase()); + } + } + result.replace("ifnotexists", "") + }; + for name in [ + "typed_ticket_assignment_tracking_history", + "typed_ticket_assignment_tracking_operations", + "typed_ticket_assignment_tracking_initialize", + "typed_ticket_assignment_tracking_assigned", + "typed_ticket_assignment_tracking_history_no_update", + "typed_ticket_assignment_tracking_history_no_delete", + "typed_ticket_assignment_tracking_operations_no_update", + "typed_ticket_assignment_tracking_operations_no_delete", + "ticket_current_singleton_role_idx", + "typed_ticket_assignment_identity_no_update", + "typed_ticket_assignment_identity_no_delete", + "typed_ticket_assignment_events_no_update", + "typed_ticket_assignment_events_no_delete", + "typed_ticket_assignment_receipt_no_update", + "typed_ticket_assignment_receipt_no_delete", + "typed_ticket_assignment_current_principal_insert", + "typed_ticket_assignment_current_principal_update", + "typed_ticket_assignment_orchestrator_principal_insert", + "typed_ticket_assignment_released_tracking_no_update", + "typed_ticket_assignment_parent_insert", + "typed_ticket_assignment_role_principal_insert", + "typed_ticket_assignment_audit_parent_insert", + "typed_ticket_assignment_operation_parent_insert", + ] { + let expected: String = canonical + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [name], + |row| row.get(0), + ) + .map_err(sqlite_err)?; + let actual: Option = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [name], + |row| row.get(0), + ) + .optional() + .map_err(sqlite_err)?; + if actual.map(&normalize).as_deref() != Some(normalize(expected).as_str()) { + return Err(TicketError::Sqlite(format!( + "Ticket assignment tracking constraint {name} differs from canonical schema" + ))); + } + } + for (table, columns) in [ + ( + "ticket_worker_assignments", + vec![ + "workspace_id", + "ticket_id", + "assignment_id", + "role", + "principal_kind", + "principal_id", + "runtime_id", + "worker_id", + "assigned_by", + "assigned_at", + "tracking_json", + "last_tracking_event_id", + "tracking_updated_at", + "tracking_migration_diagnostic", + ], + ), + ( + "ticket_current_worker_assignments", + vec![ + "workspace_id", + "ticket_id", + "role", + "assignment_id", + "principal_kind", + "principal_id", + "runtime_id", + "worker_id", + "updated_at", + ], + ), + ( + "ticket_worker_assignment_events", + vec![ + "workspace_id", + "ticket_id", + "role", + "event_id", + "action", + "assignment_id", + "previous_assignment_id", + "actor", + "created_at", + "operation_id", + "reason", + ], + ), + ( + "ticket_assignment_operations", + vec![ + "workspace_id", + "operation_id", + "action", + "ticket_id", + "role", + "principal_kind", + "principal_id", + "runtime_id", + "worker_id", + "assignment_id", + "expected_assignment_id", + "created_at", + "request_fingerprint", + "claim_state", + "failure_reason", + "binding_recovery_json", + ], + ), + ( + "typed_ticket_assignment_tracking_history", + vec![ + "workspace_id", + "ticket_id", + "assignment_id", + "event_id", + "operation_key", + "tracking_json", + "source_context_json", + "at", + ], + ), + ( + "typed_ticket_assignment_tracking_operations", + vec![ + "workspace_id", + "operation_key", + "ticket_id", + "input_json", + "source_context_json", + "result_json", + ], + ), + ] { + let actual = conn + .prepare(&format!("PRAGMA table_info({table})")) + .map_err(sqlite_err)? + .query_map([], |r| r.get::<_, String>(1)) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + if actual != columns { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} has unexpected columns" + ))); + } + // Verify type, nullability, defaults and key positions, not just names. + let shape = |db: &Connection| -> Result, i64)>> { + db.prepare(&format!("PRAGMA table_info({table})")) + .map_err(sqlite_err)? + .query_map([], |r| { + Ok((r.get(1)?, r.get(2)?, r.get(3)?, r.get(4)?, r.get(5)?)) + }) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err) + }; + if shape(conn)? != shape(&canonical)? { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} type/default/key constraints differ" + ))); + } + let foreign_keys = |db: &Connection| -> Result> { + db.prepare(&format!("PRAGMA foreign_key_list({table})")) + .map_err(sqlite_err)? + .query_map([], |r| { + Ok(( + r.get::<_, String>(2)?, + r.get::<_, String>(3)?, + r.get::<_, String>(4)?, + r.get::<_, String>(6)?, + )) + }) + .map_err(sqlite_err)? + .filter_map(|r| match r { + Ok(key) if key.0 == "workspaces" => None, + other => Some(other), + }) + .collect::, _>>() + .map_err(sqlite_err) + }; + if !foreign_keys(&canonical)?.is_subset(&foreign_keys(conn)?) { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} lacks canonical foreign keys" + ))); + } + let expected_sql: String = canonical + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [table], + |r| r.get(0), + ) + .map_err(sqlite_err)?; + let actual_sql: String = conn + .query_row( + "SELECT sql FROM sqlite_schema WHERE name=?1", + [table], + |r| r.get(0), + ) + .map_err(sqlite_err)?; + let expected_sql = normalize(expected_sql); + let actual_sql = normalize(actual_sql); + let mut remainder = expected_sql.as_str(); + while let Some(start) = remainder.find("check(") { + let check = &remainder[start..]; + let mut depth = 0i64; + let mut end = None; + for (index, ch) in check.char_indices().skip(5) { + if ch == '(' { + depth += 1; + } else if ch == ')' { + depth -= 1; + if depth == 0 { + end = Some(index + 1); + break; + } + } + } + let end = end.ok_or_else(|| { + TicketError::Sqlite("canonical assignment CHECK is malformed".into()) + })?; + if !actual_sql.contains(&check[..end]) { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} CHECK constraint differs" + ))); + } + remainder = &check[end..]; + } + let index_keys = |db: &Connection| -> Result)>> { + let indices = db + .prepare(&format!("PRAGMA index_list({table})")) + .map_err(sqlite_err)? + .query_map([], |r| { + Ok(( + r.get::<_, String>(1)?, + r.get::<_, bool>(2)?, + r.get::<_, bool>(4)?, + )) + }) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + indices + .into_iter() + .map(|(name, unique, partial)| { + let columns = db + .prepare(&format!("PRAGMA index_info({name})")) + .map_err(sqlite_err)? + .query_map([], |r| r.get::<_, String>(2)) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + Ok((unique, partial, columns)) + }) + .collect() + }; + if !index_keys(&canonical)?.is_subset(&index_keys(conn)?) { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} lacks canonical key/index constraints" + ))); + } + let violations: i64 = conn + .query_row( + &format!("SELECT COUNT(*) FROM pragma_foreign_key_check('{table}')"), + [], + |r| r.get(0), + ) + .map_err(sqlite_err)?; + if violations != 0 { + return Err(TicketError::Sqlite(format!( + "Ticket-owned assignment schema {table} foreign key violations" + ))); + } + } + for name in [ + "typed_ticket_assignment_tracking_initialize", + "typed_ticket_assignment_tracking_assigned", + "typed_ticket_assignment_tracking_history_no_update", + "typed_ticket_assignment_tracking_history_no_delete", + "typed_ticket_assignment_tracking_operations_no_update", + "typed_ticket_assignment_tracking_operations_no_delete", + ] { + let exists: bool = conn + .query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type='trigger' AND name=?1)", + [name], + |r| r.get(0), + ) + .map_err(sqlite_err)?; + if !exists { + return Err(TicketError::Sqlite(format!( + "Ticket assignment tracking constraint {name} missing" + ))); + } + } + Ok(()) +} + +#[cfg(test)] +#[path = "assignment_tracking_tests.rs"] +mod tests; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentsPage { + pub items: Vec, + pub next_assignment_id: Option, +} + +/// Immutable append-order identities. The cursor must be an identity in this +/// Ticket/Workspace; timestamps and lexical IDs are not insertion order. +pub fn sqlite_ticket_assignments_page( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, +) -> Result { + if !(1..=100).contains(&limit) { + return Err(TicketError::Conflict( + "Assignment page limit must be 1..100".into(), + )); + } + let after: i64 = match after_assignment_id { + None => 0, + Some(id) => conn.query_row("SELECT rowid FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND assignment_id=?3",params![workspace_id,ticket_id,id],|r| r.get(0)).optional().map_err(sqlite_err)? + .ok_or_else(|| TicketError::Conflict("Assignment cursor does not belong to this Ticket".into()))?, + }; + let mut stmt = conn.prepare("SELECT assignment_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='orchestrator' AND rowid>?3 ORDER BY rowid LIMIT ?4").map_err(sqlite_err)?; + let mut ids = stmt + .query_map( + params![workspace_id, ticket_id, after, (limit + 1) as i64], + |r| r.get::<_, String>(0), + ) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)?; + let has_more = ids.len() > limit; + ids.truncate(limit); + let next_assignment_id = if has_more { ids.last().cloned() } else { None }; + let items = ids + .iter() + .map(|id| { + sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, id)? + .ok_or_else(|| TicketError::Conflict("Assignment disappeared while paging".into())) + }) + .collect::>>()?; + Ok(TicketAssignmentsPage { + items, + next_assignment_id, + }) +} diff --git a/crates/ticket/src/assignment_tracking_schema.sql b/crates/ticket/src/assignment_tracking_schema.sql new file mode 100644 index 000000000..71bdb0ac1 --- /dev/null +++ b/crates/ticket/src/assignment_tracking_schema.sql @@ -0,0 +1,128 @@ +-- Applied only by component 10 -> 11, never by request handlers. +ALTER TABLE ticket_worker_assignments ADD COLUMN tracking_json TEXT + CHECK(tracking_json IS NULL OR (role = 'orchestrator' AND json_valid(tracking_json) AND json_type(tracking_json)='object' + AND COALESCE(json_extract(tracking_json, '$.status') IN ('waiting', 'inprogress', 'done'), 0))); +ALTER TABLE ticket_worker_assignments ADD COLUMN last_tracking_event_id TEXT; +ALTER TABLE ticket_worker_assignments ADD COLUMN tracking_updated_at TEXT; +ALTER TABLE ticket_worker_assignments ADD COLUMN tracking_migration_diagnostic TEXT; +CREATE TABLE typed_ticket_assignment_tracking_history ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + event_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + tracking_json TEXT NOT NULL CHECK(json_valid(tracking_json)), + source_context_json TEXT NOT NULL CHECK(json_valid(source_context_json)), + at TEXT NOT NULL, + PRIMARY KEY(workspace_id, event_id), + UNIQUE(workspace_id, assignment_id, operation_key), + FOREIGN KEY(workspace_id, ticket_id, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, ticket_id, assignment_id) +); +CREATE TABLE typed_ticket_assignment_tracking_operations ( + workspace_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + ticket_id TEXT NOT NULL, + input_json TEXT NOT NULL, + source_context_json TEXT NOT NULL, + result_json TEXT NOT NULL, + PRIMARY KEY(workspace_id, operation_key) +); +CREATE TRIGGER typed_ticket_assignment_tracking_initialize +AFTER INSERT ON ticket_worker_assignments WHEN NEW.role = 'orchestrator' +BEGIN + UPDATE ticket_worker_assignments SET tracking_json = '{"status":"waiting","waiting_reason":"Initial judgment pending","progress":null,"next_action":null,"result":null}', tracking_updated_at = NEW.assigned_at + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id; +END; +CREATE TRIGGER typed_ticket_assignment_tracking_assigned +AFTER INSERT ON ticket_worker_assignment_events +WHEN NEW.role = 'orchestrator' AND NEW.action IN ('assigned', 'reassigned') +BEGIN + UPDATE ticket_worker_assignments SET last_tracking_event_id = NEW.event_id, tracking_updated_at = NEW.created_at + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id AND tracking_json IS NOT NULL; + INSERT INTO typed_ticket_assignment_tracking_history + SELECT workspace_id, ticket_id, assignment_id, NEW.event_id, COALESCE(NEW.operation_id, NEW.event_id), tracking_json, + json_object('actor', NEW.actor, 'kind', 'assignment'), NEW.created_at + FROM ticket_worker_assignments + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id AND tracking_json IS NOT NULL; +END; +CREATE TRIGGER typed_ticket_assignment_tracking_history_no_update +BEFORE UPDATE ON typed_ticket_assignment_tracking_history +BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking history is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_tracking_history_no_delete +BEFORE DELETE ON typed_ticket_assignment_tracking_history +BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking history is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_tracking_operations_no_update +BEFORE UPDATE ON typed_ticket_assignment_tracking_operations +BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking receipts are immutable'); END; +CREATE TRIGGER typed_ticket_assignment_tracking_operations_no_delete +BEFORE DELETE ON typed_ticket_assignment_tracking_operations +BEGIN SELECT RAISE(ABORT, 'Ticket assignment tracking receipts are immutable'); END; +-- Original assignment identity/principal/audit is append-only. Tracking is the +-- only mutable assignment value; startup claims are mutable only while pending. +CREATE TRIGGER typed_ticket_assignment_identity_no_update +BEFORE UPDATE OF workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at ON ticket_worker_assignments +WHEN OLD.workspace_id IS NOT NEW.workspace_id OR OLD.ticket_id IS NOT NEW.ticket_id + OR OLD.assignment_id IS NOT NEW.assignment_id OR OLD.role IS NOT NEW.role + OR OLD.principal_kind IS NOT NEW.principal_kind OR OLD.principal_id IS NOT NEW.principal_id + OR OLD.runtime_id IS NOT NEW.runtime_id OR OLD.worker_id IS NOT NEW.worker_id + OR OLD.assigned_by IS NOT NEW.assigned_by OR OLD.assigned_at IS NOT NEW.assigned_at +BEGIN SELECT RAISE(ABORT, 'Ticket assignment identity is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_identity_no_delete BEFORE DELETE ON ticket_worker_assignments +BEGIN SELECT RAISE(ABORT, 'Ticket assignment identity is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_events_no_update BEFORE UPDATE ON ticket_worker_assignment_events +BEGIN SELECT RAISE(ABORT, 'Ticket assignment audit is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_events_no_delete BEFORE DELETE ON ticket_worker_assignment_events +BEGIN SELECT RAISE(ABORT, 'Ticket assignment audit is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_receipt_no_update BEFORE UPDATE ON ticket_assignment_operations +WHEN OLD.claim_state != 'pending' +BEGIN SELECT RAISE(ABORT, 'Ticket assignment terminal receipt is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_receipt_no_delete BEFORE DELETE ON ticket_assignment_operations +WHEN OLD.claim_state != 'pending' +BEGIN SELECT RAISE(ABORT, 'Ticket assignment terminal receipt is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_current_principal_insert BEFORE INSERT ON ticket_current_worker_assignments +WHEN NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id + AND a.ticket_id=NEW.ticket_id AND a.assignment_id=NEW.assignment_id AND a.role=NEW.role + AND a.principal_kind=NEW.principal_kind AND a.principal_id IS NEW.principal_id + AND a.runtime_id IS NEW.runtime_id AND a.worker_id IS NEW.worker_id) + OR EXISTS (SELECT 1 FROM ticket_worker_assignment_events e WHERE e.workspace_id=NEW.workspace_id + AND ((e.action='unassigned' AND (e.assignment_id=NEW.assignment_id OR e.previous_assignment_id=NEW.assignment_id)) + OR (e.action='reassigned' AND e.previous_assignment_id=NEW.assignment_id))) +BEGIN SELECT RAISE(ABORT, 'Ticket current assignment must match an unreleased immutable identity'); END; +CREATE TRIGGER typed_ticket_assignment_current_principal_update BEFORE UPDATE ON ticket_current_worker_assignments +WHEN NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id + AND a.ticket_id=NEW.ticket_id AND a.assignment_id=NEW.assignment_id AND a.role=NEW.role + AND a.principal_kind=NEW.principal_kind AND a.principal_id IS NEW.principal_id + AND a.runtime_id IS NEW.runtime_id AND a.worker_id IS NEW.worker_id) + OR EXISTS (SELECT 1 FROM ticket_worker_assignment_events e WHERE e.workspace_id=NEW.workspace_id + AND ((e.action='unassigned' AND (e.assignment_id=NEW.assignment_id OR e.previous_assignment_id=NEW.assignment_id)) + OR (e.action='reassigned' AND e.previous_assignment_id=NEW.assignment_id))) +BEGIN SELECT RAISE(ABORT, 'Ticket current assignment must match an unreleased immutable identity'); END; +CREATE TRIGGER typed_ticket_assignment_orchestrator_principal_insert BEFORE INSERT ON ticket_worker_assignments +WHEN NEW.role='orchestrator' AND (NEW.principal_kind!='workspace_agent' OR NEW.principal_id!='workspace-orchestrator') +BEGIN SELECT RAISE(ABORT, 'Orchestrator assignment requires the stable Workspace agent principal'); END; +CREATE TRIGGER typed_ticket_assignment_released_tracking_no_update +BEFORE UPDATE OF tracking_json,last_tracking_event_id,tracking_updated_at ON ticket_worker_assignments +WHEN EXISTS (SELECT 1 FROM ticket_worker_assignment_events e WHERE e.workspace_id=OLD.workspace_id + AND ((e.action='unassigned' AND (e.assignment_id=OLD.assignment_id OR e.previous_assignment_id=OLD.assignment_id)) + OR (e.action='reassigned' AND e.previous_assignment_id=OLD.assignment_id))) +BEGIN SELECT RAISE(ABORT, 'Released assignment tracking is immutable'); END; +CREATE TRIGGER typed_ticket_assignment_parent_insert BEFORE INSERT ON ticket_worker_assignments +WHEN NOT EXISTS (SELECT 1 FROM typed_tickets t WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id) +BEGIN SELECT RAISE(ABORT, 'New assignment requires a Ticket in the same Workspace'); END; +CREATE TRIGGER typed_ticket_assignment_role_principal_insert BEFORE INSERT ON ticket_worker_assignments +WHEN (NEW.role='owner' AND NEW.principal_kind!='user') + OR (NEW.role='worker' AND NEW.principal_kind='workspace_agent') + OR (NEW.principal_kind='workspace_agent' AND NEW.principal_id!='workspace-orchestrator') +BEGIN SELECT RAISE(ABORT, 'Assignment role and principal are incompatible'); END; +CREATE TRIGGER typed_ticket_assignment_audit_parent_insert BEFORE INSERT ON ticket_worker_assignment_events +WHEN (NEW.assignment_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id AND a.role=NEW.role AND a.assignment_id=NEW.assignment_id)) + OR (NEW.previous_assignment_id IS NOT NULL AND NOT EXISTS (SELECT 1 FROM ticket_worker_assignments a WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id AND a.role=NEW.role AND a.assignment_id=NEW.previous_assignment_id)) +BEGIN SELECT RAISE(ABORT, 'Assignment audit must match the same immutable Ticket role'); END; +CREATE TRIGGER typed_ticket_assignment_operation_parent_insert BEFORE INSERT ON ticket_assignment_operations +WHEN NOT EXISTS (SELECT 1 FROM typed_tickets t WHERE t.workspace_id=NEW.workspace_id AND t.ticket_id=NEW.ticket_id) +BEGIN SELECT RAISE(ABORT, 'New assignment operation requires a Ticket in the same Workspace'); END; + +-- Equality scope keys leave SQLite's implicit rowid as stable append ordering. +CREATE INDEX typed_ticket_assignment_tracking_history_scope +ON typed_ticket_assignment_tracking_history(workspace_id,ticket_id,assignment_id); diff --git a/crates/ticket/src/assignment_tracking_tests.rs b/crates/ticket/src/assignment_tracking_tests.rs new file mode 100644 index 000000000..c5ea6d1c3 --- /dev/null +++ b/crates/ticket/src/assignment_tracking_tests.rs @@ -0,0 +1,831 @@ +use super::*; + +fn db() -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("PRAGMA foreign_keys=ON").unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + seed_ticket(&conn, "w", "t"); + conn +} + +fn seed_ticket(conn: &Connection, workspace: &str, ticket: &str) { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES (?1,?2,?2,'Title','open','task','P2','body','open',1)",params![workspace,ticket]).unwrap(); +} + +fn assign(conn: &Connection, id: &str, event: &str) { + let previous: Option = conn.query_row("SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[],|r|r.get(0)).optional().unwrap(); + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','author','2026-10-10T00:00:00Z')",[id]).unwrap(); + conn.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='t' AND role='orchestrator'",[]).unwrap(); + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES ('w','t',?1,'orchestrator','workspace_agent','workspace-orchestrator','now')",[id]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,previous_assignment_id,actor,created_at,operation_id) VALUES ('w','t','orchestrator',?2,?4,?1,?3,'author','now',?2)",params![id,event,previous,if previous.is_some(){"reassigned"}else{"assigned"}]).unwrap(); + conn.execute_batch("COMMIT").unwrap(); +} + +fn source() -> TicketAssignmentTrackingSourceContext { + TicketAssignmentTrackingSourceContext { + actor: "orchestrator".into(), + runtime_id: "runtime".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "binding".into(), + } +} +fn update( + id: &str, + event: &str, + key: &str, + status: TicketAssignmentTrackingStatus, +) -> TicketAssignmentTrackingUpdate { + TicketAssignmentTrackingUpdate { + assignment_id: id.into(), + expected_last_event_id: event.into(), + operation_key: key.into(), + tracking: TicketAssignmentTracking { + status, + waiting_reason: None, + progress: Some("Considering request".into()), + next_action: None, + result: Some("Returned alternatives".into()), + }, + } +} +fn apply( + conn: &Connection, + input: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, +) -> Result { + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + let result = + sqlite_update_ticket_assignment_tracking_in_transaction(conn, "w", "t", input, source); + conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" }) + .unwrap(); + result +} + +#[test] +fn assignment_initializes_waiting_and_audit_atomically() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let projection = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert!(projection.current); + assert_eq!(projection.last_event_id.as_deref(), Some("assigned-a")); + assert_eq!( + projection.tracking.unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + let history = sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(); + assert_eq!(history.len(), 1); + assert_eq!(history[0].event_id, "assigned-a"); + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES ('w','t','rolled-back','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap(); + conn.execute_batch("ROLLBACK").unwrap(); + assert!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "rolled-back") + .unwrap() + .is_none() + ); +} + +#[test] +fn direct_done_close_reopen_and_removal_keep_last_result_and_exact_replay() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let input = update( + "a", + "assigned-a", + "done", + TicketAssignmentTrackingStatus::Done, + ); + let result = apply(&conn, &input, &source()).unwrap(); + assert_eq!( + conn.query_row("SELECT workflow_state FROM typed_tickets", [], |r| r + .get::<_, String>(0)) + .unwrap(), + "open" + ); + conn.execute("UPDATE typed_tickets SET workflow_state='closed'", []) + .unwrap(); + let input2 = update( + "a", + result.last_event_id.as_deref().unwrap(), + "correct-after-close", + TicketAssignmentTrackingStatus::Waiting, + ); + let corrected = apply(&conn, &input2, &source()).unwrap(); + assert_eq!( + conn.query_row("SELECT workflow_state FROM typed_tickets", [], |r| r + .get::<_, String>(0)) + .unwrap(), + "closed" + ); + conn.execute("UPDATE typed_tickets SET workflow_state='open'", []) + .unwrap(); + assert_eq!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(), + corrected + ); + assign(&conn, "b", "assigned-b"); + assert_eq!(apply(&conn, &input, &source()).unwrap(), result); + assert_eq!(apply(&conn, &input2, &source()).unwrap(), corrected); + assert!( + apply( + &conn, + &update( + "a", + corrected.last_event_id.as_deref().unwrap(), + "old-new", + TicketAssignmentTrackingStatus::Done + ), + &source() + ) + .is_err() + ); + let old = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert!(!old.current); + assert_eq!(old.tracking, corrected.tracking); + let b = sqlite_ticket_assignment_tracking(&conn, "w", "t", "b") + .unwrap() + .unwrap(); + assert_eq!( + b.tracking.unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + conn.execute("DELETE FROM ticket_current_worker_assignments", []) + .unwrap(); + assert!( + apply( + &conn, + &update( + "b", + "assigned-b", + "removed-new", + TicketAssignmentTrackingStatus::Done + ), + &source() + ) + .is_err() + ); + assert!( + sqlite_current_ticket_assignment_tracking(&conn, "w", "t") + .unwrap() + .is_empty() + ); + assert_eq!( + sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a") + .unwrap() + .len(), + 3 + ); +} + +#[test] +fn tracking_cas_and_receipts_bind_full_payload_and_authenticated_source() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let input = update( + "a", + "assigned-a", + "update", + TicketAssignmentTrackingStatus::Inprogress, + ); + let result = apply(&conn, &input, &source()).unwrap(); + assert_eq!(apply(&conn, &input, &source()).unwrap(), result); + let mut changed = input.clone(); + changed.tracking.result = Some("Different result".into()); + assert!(apply(&conn, &changed, &source()).is_err()); + changed = input.clone(); + changed.operation_key = "fresh-stale".into(); + assert!(apply(&conn, &changed, &source()).is_err()); + for change in 0..5 { + let mut sender = source(); + match change { + 0 => sender.actor = "other".into(), + 1 => sender.runtime_id = "other".into(), + 2 => sender.worker_id = "replacement".into(), + 3 => sender.authentication_binding_id = "replacement-binding".into(), + _ => sender.agent_key = "other-agent".into(), + }; + assert!(apply(&conn, &input, &sender).is_err()); + } + let mut other = source(); + other.worker_id = "replacement".into(); + // Backend authenticated the replacement as the current stable agent. A new + // operation can proceed on the same assignment without starting a new row. + let next = update( + "a", + result.last_event_id.as_deref().unwrap(), + "replacement-update", + TicketAssignmentTrackingStatus::Done, + ); + apply(&conn, &next, &other).unwrap(); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| { + r.get::<_, i64>(0) + }) + .unwrap(), + 1 + ); + assert!( + sqlite_ticket_assignment_tracking(&conn, "other", "t", "a") + .unwrap() + .is_none() + ); + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + assert!( + sqlite_update_ticket_assignment_tracking_in_transaction( + &conn, + "other", + "t", + &input, + &source() + ) + .is_err() + ); + conn.execute_batch("ROLLBACK").unwrap(); + assert!( + conn.execute( + "UPDATE typed_ticket_assignment_tracking_history SET at='tamper'", + [] + ) + .is_err() + ); + assert!( + conn.execute( + "DELETE FROM typed_ticket_assignment_tracking_operations", + [] + ) + .is_err() + ); +} + +#[test] +fn tracking_reopens_database_without_restarting_or_reassigning_workers() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("ticket.db"); + { + let conn = Connection::open(&path).unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + seed_ticket(&conn, "w", "t"); + assign(&conn, "a", "assigned-a"); + apply( + &conn, + &update( + "a", + "assigned-a", + "done", + TicketAssignmentTrackingStatus::Done, + ), + &source(), + ) + .unwrap(); + } + let conn = Connection::open(&path).unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!( + sqlite_current_ticket_assignment_tracking(&conn, "w", "t").unwrap()[0] + .tracking + .as_ref() + .unwrap() + .status, + TicketAssignmentTrackingStatus::Done + ); + assert_eq!( + sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a") + .unwrap() + .len(), + 2 + ); +} + +fn legacy(original: &str, fenced: bool, later_case: bool, reassigned: bool) -> Connection { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch(include_str!("latest_schema.sql")) + .unwrap(); + conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-time');").unwrap(); + install_assignment_core(&conn).unwrap(); + seed_ticket(&conn, "w", "t"); + assign(&conn, "a", "assigned-a"); + let snapshot = TicketLegacyWorkflowSnapshot { + workflow_state: original.into(), + workflow_state_explicit: true, + status: original.into(), + queued_by: Some("author".into()), + queued_at: Some("original-queue-time".into()), + resolution: None, + }; + if original != "no-snapshot" { + conn.execute( + "INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES('w','t',?1)", + [serde_json::to_string(&snapshot).unwrap()], + ) + .unwrap(); + } + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,to_state,at,body) VALUES('w','t',1,'state_changed','queued','original-queue-time','Queue')",[]).unwrap(); + if fenced { + conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('w','t',1,'orchestrator_assignment_id','a')",[]).unwrap(); + } + if later_case { + conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','t',0,'closed-later','case-fingerprint','{}')",[]).unwrap(); + } + if reassigned { + assign(&conn, "b", "assigned-b"); + } + conn +} + +#[test] +fn migration_preserves_original_records_and_only_reconciles_current_fenced_delegation() { + for (original, fenced, later_case, reassigned, expected) in [ + ( + "queued", + true, + false, + false, + Some(TicketAssignmentTrackingStatus::Waiting), + ), + ( + "inprogress", + true, + false, + false, + Some(TicketAssignmentTrackingStatus::Inprogress), + ), + ("queued", false, false, false, None), + ("queued", true, true, false, None), + ("inprogress", true, false, true, None), + ("ready", true, false, false, None), + ("done", true, false, false, None), + ("closed", true, false, false, None), + ] { + let conn = legacy(original, fenced, later_case, reassigned); + let original_snapshot: String = conn + .query_row( + "SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot", + [], + |r| r.get(0), + ) + .unwrap(); + let original_event: String = conn + .query_row( + "SELECT event_id FROM ticket_worker_assignment_events ORDER BY event_id LIMIT 1", + [], + |r| r.get(0), + ) + .unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!( + conn.query_row( + "SELECT applied_at FROM ticket_schema_migrations WHERE version=10", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + "original-time" + ); + assert_eq!( + conn.query_row( + "SELECT MAX(version) FROM ticket_schema_migrations", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 11 + ); + let value = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert_eq!( + value.tracking.as_ref().map(|t| t.status), + expected, + "{original}/{fenced}/{later_case}/{reassigned}" + ); + assert!(value.migration_diagnostic.is_some()); + assert_eq!( + conn.query_row( + "SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + original_snapshot + ); + assert_eq!( + conn.query_row( + "SELECT event_id FROM ticket_worker_assignment_events ORDER BY event_id LIMIT 1", + [], + |r| r.get::<_, String>(0) + ) + .unwrap(), + original_event + ); + let history = sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert_eq!( + sqlite_ticket_assignment_tracking_history(&conn, "w", "t", "a").unwrap(), + history + ); + if reassigned { + assert!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "b") + .unwrap() + .unwrap() + .tracking + .is_none() + ); + } + } +} + +#[test] +fn tracking_schema_rejects_drift_and_duplicate_current_singleton_roles() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','author','now')",[]).unwrap(); + assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err()); + conn.execute_batch("DROP TRIGGER typed_ticket_assignment_tracking_history_no_update; CREATE TRIGGER typed_ticket_assignment_tracking_history_no_update BEFORE UPDATE ON typed_ticket_assignment_tracking_history BEGIN SELECT 1; END;").unwrap(); + assert!( + verify_sqlite_ticket_schema(&conn) + .unwrap_err() + .to_string() + .contains("differs from canonical schema") + ); + assert!(migrate_sqlite_ticket_schema(&conn).is_err()); +} + +#[test] +fn tracking_input_wire_contract_rejects_unknown_fields_and_statuses() { + let input = update( + "a", + "event", + "operation", + TicketAssignmentTrackingStatus::Inprogress, + ); + let mut value = serde_json::to_value(&input).unwrap(); + assert_eq!(value["tracking"]["status"], "inprogress"); + value["claimed_role"] = serde_json::json!("orchestrator"); + assert!(serde_json::from_value::(value).is_err()); + let mut value = serde_json::to_value(&input).unwrap(); + value["tracking"]["status"] = serde_json::json!("running"); + assert!(serde_json::from_value::(value).is_err()); +} + +#[test] +fn standalone_assignment_identity_audit_and_terminal_receipts_are_immutable_but_pending_claims_can_finish() + { + let conn = db(); + assign(&conn, "a", "assigned-a"); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w','assigned-op','assign','t','orchestrator','workspace_agent','workspace-orchestrator','a','original-time','original-fingerprint')",[]).unwrap(); + for sql in [ + "UPDATE ticket_worker_assignments SET assignment_id='changed' WHERE assignment_id='a'", + "UPDATE ticket_worker_assignments SET assigned_by='forged' WHERE assignment_id='a'", + "UPDATE ticket_worker_assignments SET assigned_at='forged-time' WHERE assignment_id='a'", + "UPDATE ticket_worker_assignments SET role='owner',principal_kind='user',principal_id='user' WHERE assignment_id='a'", + "DELETE FROM ticket_worker_assignments WHERE assignment_id='a'", + "UPDATE ticket_worker_assignment_events SET actor='forged'", + "UPDATE ticket_worker_assignment_events SET event_id='forged'", + "DELETE FROM ticket_worker_assignment_events", + "UPDATE ticket_assignment_operations SET request_fingerprint='forged' WHERE operation_id='assigned-op'", + "UPDATE ticket_assignment_operations SET created_at='forged' WHERE operation_id='assigned-op'", + "DELETE FROM ticket_assignment_operations WHERE operation_id='assigned-op'", + "UPDATE ticket_current_worker_assignments SET principal_id='another-agent'", + "UPDATE ticket_worker_assignments SET tracking_json='{}' WHERE assignment_id='a'", + ] { + assert!( + conn.execute(sql, []).is_err(), + "deny direct mutation: {sql}" + ); + } + // The immutable original principal also fences insertion of a mismatched + // current row, even in a standalone Ticket store without host constraints. + conn.execute("DELETE FROM ticket_current_worker_assignments", []) + .unwrap(); + assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','a','orchestrator','workspace_agent','different-agent','now')",[]).is_err()); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state) VALUES('w','pending','assign','t','worker','worker','runtime','pending-worker','claim-time','claim-fingerprint','pending')",[]).unwrap(); + conn.execute("UPDATE ticket_assignment_operations SET worker_id='resolved-worker',binding_recovery_json='{}' WHERE operation_id='pending'",[]).unwrap(); + conn.execute("UPDATE ticket_assignment_operations SET claim_state='failed',failure_reason='Admission failed' WHERE operation_id='pending'",[]).unwrap(); + assert!(conn.execute("UPDATE ticket_assignment_operations SET failure_reason='rewrite' WHERE operation_id='pending'",[]).is_err()); + assert!( + conn.execute( + "DELETE FROM ticket_assignment_operations WHERE operation_id='pending'", + [] + ) + .is_err() + ); + verify_sqlite_ticket_schema(&conn).unwrap(); +} + +#[test] +fn release_projection_preserves_actor_time_reason_and_optional_legacy_tracking() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let result = apply( + &conn, + &update( + "a", + "assigned-a", + "result", + TicketAssignmentTrackingStatus::Done, + ), + &source(), + ) + .unwrap(); + assign(&conn, "b", "assigned-b"); + let old = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + let release = old.release.as_ref().unwrap(); + assert_eq!(release.action, TicketAssignmentReleaseAction::Replaced); + assert_eq!(release.replacement_assignment_id.as_deref(), Some("b")); + assert_eq!(release.last_tracking, result.tracking); + assert_eq!(release.actor, "author"); + assert_eq!(release.event_id, "assigned-b"); + conn.execute("DELETE FROM ticket_current_worker_assignments", []) + .unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,previous_assignment_id,actor,created_at,operation_id,reason) VALUES('w','t','orchestrator','unassign-b','unassigned','b','human','original-unassign-time','unassign-op','Withdrawn')",[]).unwrap(); + let removed = sqlite_ticket_assignment_tracking(&conn, "w", "t", "b") + .unwrap() + .unwrap(); + let release = removed.release.unwrap(); + assert_eq!(release.action, TicketAssignmentReleaseAction::Unassigned); + assert_eq!(release.at, "original-unassign-time"); + assert_eq!(release.reason.as_deref(), Some("Withdrawn")); + assert_eq!( + release.last_tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + assert!(conn.execute("UPDATE ticket_worker_assignments SET tracking_json='{\"status\":\"done\"}' WHERE assignment_id='b'",[]).is_err()); + assert!(conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','t','b','orchestrator','workspace_agent','workspace-orchestrator','now')",[]).is_err()); + let legacy = legacy("done", true, false, false); + migrate_sqlite_ticket_schema(&legacy).unwrap(); + legacy + .execute("DELETE FROM ticket_current_worker_assignments", []) + .unwrap(); + legacy.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,previous_assignment_id,actor,created_at,operation_id) VALUES('w','t','orchestrator','unassign-legacy','unassigned','a','human','legacy-unassign-time','legacy-unassign-op')",[]).unwrap(); + let original = sqlite_ticket_assignment_tracking(&legacy, "w", "t", "a") + .unwrap() + .unwrap(); + assert!(original.tracking.is_none()); + assert!(original.release.unwrap().last_tracking.is_none()); +} + +fn routing_queue(conn: &Connection, index: i64, fence: &str) { + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,from_state,to_state,at,state_field,reason,body) VALUES('w','t',?1,'state_changed','open','open','typed-queue-time','routing','queued','Explicit component-10 Queue')",[index]).unwrap(); + conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('w','t',?1,'orchestrator_assignment_id',?2)",params![index,fence]).unwrap(); +} + +#[test] +fn migration_recognizes_typed_case_neutral_queue_and_never_revives_an_older_mismatched_queue() { + for original in ["ready", "no-snapshot"] { + let conn = legacy(original, true, false, false); + routing_queue(&conn, 2, "a"); + migrate_sqlite_ticket_schema(&conn).unwrap(); + let assignment = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert_eq!( + assignment.tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + assert_eq!(assignment.updated_at.as_deref(), Some("typed-queue-time")); + } + let conn = legacy("queued", true, false, false); + routing_queue(&conn, 2, "mismatched-assignment"); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap() + .tracking + .is_none() + ); + let conn = legacy("no-snapshot", true, false, false); + migrate_sqlite_ticket_schema(&conn).unwrap(); + assert!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap() + .tracking + .is_none() + ); +} + +#[test] +fn migration_accepts_new_explicit_queue_after_ordered_case_judgments_but_not_after_a_later_case() { + for queue_after_case in [true, false] { + let conn = legacy("queued", true, false, false); + for (ordinal, index, action, state) in [ + ( + 0, + 2, + TicketCaseAction::Close { + reason: TicketClosureReason::Unknown, + }, + "closed", + ), + (1, 3, TicketCaseAction::Reopen, "open"), + ] { + let key = format!("case-{ordinal}"); + let judgment = TicketCaseHistoryEntry { + operation_key: key.clone(), + previous_closure_operation_key: if ordinal == 1 { + Some("case-0".into()) + } else { + None + }, + action, + explanation: "Original case decision".into(), + references: vec![], + author: "human".into(), + at: format!("case-time-{ordinal}"), + }; + conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','t',?1,?2,'original-fingerprint',?3)",params![ordinal,key,serde_json::to_string(&judgment).unwrap()]).unwrap(); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,state_field,to_state,at,body) VALUES('w','t',?1,'state_changed','case',?2,?3,'Original case decision')",params![index,state,judgment.at]).unwrap(); + conn.execute( + "INSERT INTO typed_ticket_event_attributes VALUES('w','t',?1,'operation_key',?2)", + params![index, key], + ) + .unwrap(); + } + if queue_after_case { + routing_queue(&conn, 4, "a"); + } + let original_history: i64 = conn + .query_row("SELECT COUNT(*) FROM typed_ticket_case_history", [], |r| { + r.get(0) + }) + .unwrap(); + migrate_sqlite_ticket_schema(&conn).unwrap(); + let assignment = sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap(); + assert_eq!(assignment.tracking.is_some(), queue_after_case); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM typed_ticket_case_history", [], |r| { + r.get::<_, i64>(0) + }) + .unwrap(), + original_history + ); + } +} + +#[test] +fn standalone_assignment_constraints_reject_cross_ticket_principals_or_orphan_audit_and_operations() +{ + let conn = db(); + assign(&conn, "a", "assigned-a"); + for sql in [ + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('other','t','cross-workspace','orchestrator','workspace_agent','workspace-orchestrator','author','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-orchestrator','orchestrator','user','user','author','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','t','bad-owner','owner','worker','r','worker','author','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','t','bad-worker','worker','workspace_agent','workspace-orchestrator','author','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,assigned_by,assigned_at) VALUES('w','t','missing-worker-identity','worker','worker','author','now')", + "INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('other','t','orchestrator','cross-audit','assigned','a','author','now')", + "INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at) VALUES('w','missing','orchestrator','cross-ticket-audit','assigned','a','author','now')", + "INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,created_at) VALUES('other','orphan-operation','assign','t','now')", + "INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at,tracking_json) VALUES('w','t','bad-status','orchestrator','workspace_agent','workspace-orchestrator','author','now','{\"status\":\"running\"}')", + ] { + assert!(conn.execute(sql, []).is_err(), "reject {sql}"); + } + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| { + r.get::<_, i64>(0) + }) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignment_events", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 1 + ); + verify_sqlite_ticket_schema(&conn).unwrap(); +} + +#[test] +fn schema_verification_does_not_normalize_away_case_sensitive_authority_literals() { + let conn = db(); + let sql:String=conn.query_row("SELECT sql FROM sqlite_schema WHERE name='typed_ticket_assignment_orchestrator_principal_insert'",[],|r|r.get(0)).unwrap(); + conn.execute_batch("DROP TRIGGER typed_ticket_assignment_orchestrator_principal_insert") + .unwrap(); + conn.execute_batch(&sql.replace("NEW.role='orchestrator'", "NEW.role='Orchestrator'")) + .unwrap(); + assert!( + verify_sqlite_ticket_schema(&conn) + .unwrap_err() + .to_string() + .contains("differs from canonical schema") + ); +} + +#[test] +fn assignment_answer_commits_one_audited_input_and_exact_receipt_without_tracking_mutation() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let answer = TicketAssignmentAnswer { + assignment_id: "a".into(), + expected_last_event_id: "assigned-a".into(), + operation_key: "answer-a".into(), + body: "Continue with B".into(), + }; + let source = serde_json::json!({"kind":"account","account_id":"account","user_id":"human"}); + let apply = |answer: &TicketAssignmentAnswer| { + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + let result = sqlite_answer_ticket_assignment_in_transaction( + &conn, "w", "t", answer, &source, "human", + ); + conn.execute_batch(if result.is_ok() { "COMMIT" } else { "ROLLBACK" }) + .unwrap(); + result + }; + let result = apply(&answer).unwrap(); + assert_eq!(result.input_event_id, "ticket-event:0"); + assert_eq!(apply(&answer).unwrap(), result); + assert_eq!( + conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!( + conn.query_row("SELECT body FROM typed_ticket_events", [], |r| r + .get::<_, String>(0)) + .unwrap(), + answer.body + ); + assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='orchestrator_assignment_id'",[],|r| r.get::<_,String>(0)).unwrap(),"a"); + assert_eq!( + sqlite_ticket_assignment_tracking(&conn, "w", "t", "a") + .unwrap() + .unwrap() + .last_event_id + .as_deref(), + Some("assigned-a") + ); + let mut altered = answer.clone(); + altered.body = "Changed".into(); + assert!(apply(&altered).is_err()); + assign(&conn, "b", "assigned-b"); + assert_eq!(apply(&answer).unwrap(), result); + altered.operation_key = "fresh-stale".into(); + assert!(apply(&altered).is_err()); +} + +#[test] +fn assignment_answer_rejects_unbounded_body_and_malformed_identities_without_appending_input() { + let conn = db(); + assign(&conn, "a", "assigned-a"); + let original = TicketAssignmentAnswer { + assignment_id: "a".into(), + expected_last_event_id: "assigned-a".into(), + operation_key: "answer-a".into(), + body: "Answer".into(), + }; + for field in [ + "body-large", + "body-nul", + "body-empty", + "identity-control", + "event-empty", + ] { + let mut answer = original.clone(); + match field { + "body-large" => answer.body = "a".repeat(16385), + "body-nul" => answer.body = "answer\0hidden".into(), + "body-empty" => answer.body = " ".into(), + "identity-control" => answer.operation_key = "key\n".into(), + _ => answer.expected_last_event_id = "".into(), + } + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + assert!( + sqlite_answer_ticket_assignment_in_transaction( + &conn, + "w", + "t", + &answer, + &serde_json::json!({"actor":"source"}), + "human" + ) + .is_err(), + "{field}" + ); + conn.execute_batch("ROLLBACK").unwrap(); + } + assert_eq!( + conn.query_row("SELECT count(*) FROM typed_ticket_events", [], |r| r + .get::<_, i64>(0)) + .unwrap(), + 0 + ); +} diff --git a/crates/ticket/src/case_schema.sql b/crates/ticket/src/case_schema.sql new file mode 100644 index 000000000..5c8ce639a --- /dev/null +++ b/crates/ticket/src/case_schema.sql @@ -0,0 +1,32 @@ +-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority. +CREATE TABLE typed_ticket_case_metadata ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_case_history ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, operation_key), + UNIQUE (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_legacy_workflow_snapshot ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; diff --git a/crates/ticket/src/latest_schema.sql b/crates/ticket/src/latest_schema.sql index a06926101..d62200154 100644 --- a/crates/ticket/src/latest_schema.sql +++ b/crates/ticket/src/latest_schema.sql @@ -131,3 +131,73 @@ CREATE INDEX typed_tickets_workspace_title ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id); CREATE INDEX typed_tickets_workspace_updated ON typed_tickets(workspace_id, updated_at DESC, ticket_id); + +-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority. +CREATE TABLE typed_ticket_case_metadata ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_case_history ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, operation_key), + UNIQUE (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_legacy_workflow_snapshot ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade. +CREATE TABLE workspace_tags ( + workspace_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + last_operation_key TEXT NOT NULL, + deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)), + PRIMARY KEY (workspace_id, tag_id) +); +CREATE UNIQUE INDEX workspace_tags_active_name + ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0; +CREATE TABLE typed_ticket_tags ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, tag_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT +); +CREATE INDEX typed_ticket_tags_workspace_tag + ON typed_ticket_tags(workspace_id, tag_id, ticket_id); +-- Immutable operation receipt and Workspace audit: exact input includes actor +-- attributes; before/result preserve stable identities and display-name snapshots. +-- No foreign keys to mutable rows: history survives deletion of current links. +CREATE TABLE workspace_tag_operations ( + workspace_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + kind TEXT NOT NULL, + target_id TEXT NOT NULL, + input_json TEXT NOT NULL, + source_context_json TEXT NOT NULL, + before_json TEXT NOT NULL, + result_json TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_key) +); diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index af5a3594f..d3539bca8 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -1,4 +1,13 @@ #![doc = include_str!("../README.md")] +//! +//! Case state, classification, delegation handling, Worker execution, and MR evidence +//! are separate authorities. An open case is not queued or authorized to start work; +//! a closed case records a judgment, not necessarily fulfilled requirements. +//! Historical completion events remain history rather than current case operations. +//! The Workspace host owns authentication, delivery/start fences, and operational work +//! release. Case changes do not assign or start/stop Workers, grant resources, or integrate MRs. +//! Workers use the scoped Workspace API; repository-local Ticket trees are not authority. +//! New work after reopening needs explicit routing/claim; reopening restores no execution rights. use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::fmt; @@ -16,11 +25,23 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; +mod assignment_answer; +pub use assignment_answer::*; +mod assignment_tracking; pub mod config; mod sqlite_schema; +mod tags; +pub use assignment_tracking::*; +pub use tags::{ + TagListRequest, TicketTagSetRequest, WorkspaceTag, WorkspaceTagAction, WorkspaceTagMutation, +}; pub mod tool; -pub use sqlite_schema::{migrate_sqlite_ticket_schema, verify_sqlite_ticket_schema}; +pub use sqlite_schema::{ + migrate_sqlite_ticket_schema, migrate_sqlite_ticket_schema_in_transaction, + verify_legacy_sqlite_ticket_schema, verify_pre_classification_ticket_schema, + verify_sqlite_ticket_schema, +}; const MAX_INTAKE_SUMMARY_BODY_BYTES: usize = 16 * 1024; const MAX_ORCHESTRATION_PLAN_TEXT_BYTES: usize = 16 * 1024; @@ -88,6 +109,11 @@ pub enum TicketError { StaleContent { expected: String, actual: String }, #[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")] StaleWorkflowState { expected: String, actual: String }, + #[error("stale Ticket closure operation key: expected {expected:?}, found {actual:?}")] + StaleClosureOperationKey { + expected: Option, + actual: Option, + }, #[error("invalid ticket workflow transition `{from}` -> `{to}`")] InvalidWorkflowTransition { from: String, to: String }, #[error("ticket has unresolved blocking relations: {0}")] @@ -109,9 +135,22 @@ fn io_err(path: impl Into, source: io::Error) -> TicketError { } } +fn parse_sql_case_state(raw: &str, index: usize) -> rusqlite::Result { + TicketState::parse(raw).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + index, + rusqlite::types::Type::Text, + Box::new(TicketError::Sqlite(format!( + "invalid Ticket case state {raw:?}" + ))), + ) + }) +} + fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let workflow_state = row.get::<_, String>(7)?; Ok(TicketSummary { + tags: Vec::new(), id: row.get(0)?, resource_key: None, slug: row.get(1)?, @@ -121,8 +160,7 @@ fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(8)? != 0, queued_by: row.get(9)?, queued_at: row.get(10)?, @@ -235,61 +273,34 @@ impl From for ExtensibleTicketStatus { schemars::JsonSchema, )] #[serde(rename_all = "snake_case")] -pub enum TicketWorkflowState { - Planning, - Ready, - Queued, - InProgress, - Done, +pub enum TicketState { + Open, Closed, } -impl TicketWorkflowState { +impl TicketState { pub fn as_str(self) -> &'static str { match self { - Self::Planning => "planning", - Self::Ready => "ready", - Self::Queued => "queued", - Self::InProgress => "inprogress", - Self::Done => "done", + Self::Open => "open", Self::Closed => "closed", } } - pub fn parse(value: &str) -> Option { match value { - "planning" => Some(Self::Planning), - "ready" => Some(Self::Ready), - "queued" => Some(Self::Queued), - "inprogress" => Some(Self::InProgress), - "done" => Some(Self::Done), + "open" => Some(Self::Open), "closed" => Some(Self::Closed), _ => None, } } - pub fn default_for_status(status: &ExtensibleTicketStatus) -> Self { match status { ExtensibleTicketStatus::Closed => Self::Closed, - _ => Self::Planning, + _ => Self::Open, } } - - pub fn is_planning_ready_transition(from: Self, to: Self) -> bool { - from == Self::Planning && to == Self::Ready - } - - pub fn is_queue_transition(_from: Self, to: Self) -> bool { - to == Self::Queued - } - - /// Compatibility helper for internal callers; state is not a role phase path. - pub fn is_role_transition(from: Self, to: Self) -> bool { - from != to - } } -impl fmt::Display for TicketWorkflowState { +impl fmt::Display for TicketState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } @@ -356,6 +367,7 @@ pub enum TicketEventKind { Decision, ImplementationReport, StateChanged, + TagsChanged, IntakeSummary, StatusChanged, Close, @@ -371,6 +383,7 @@ impl TicketEventKind { Self::Decision => "decision", Self::ImplementationReport => "implementation_report", Self::StateChanged => "state_changed", + Self::TagsChanged => "tags_changed", Self::IntakeSummary => "intake_summary", Self::StatusChanged => "status_changed", Self::Close => "close", @@ -386,6 +399,7 @@ impl TicketEventKind { Self::Decision => "Decision".to_string(), Self::ImplementationReport => "Implementation report".to_string(), Self::StateChanged => "State changed".to_string(), + Self::TagsChanged => "Tags changed".to_string(), Self::IntakeSummary => "Intake summary".to_string(), Self::StatusChanged => "Status changed".to_string(), Self::Close => "Closed".to_string(), @@ -404,6 +418,7 @@ impl From<&str> for TicketEventKind { "implementation_report" => Self::ImplementationReport, "review" => Self::Comment, "state_changed" => Self::StateChanged, + "tags_changed" => Self::TagsChanged, "intake_summary" => Self::IntakeSummary, "status_changed" => Self::StatusChanged, "close" | "closed" => Self::Close, @@ -465,56 +480,118 @@ impl TicketStateChange { } } -/// Audited completion independent of repositories, Git, or Merge Requests. +/// Reason attached to a closure judgment, not a classification tag or success inference. +/// Only `Completed` asserts fulfillment; the other reasons can end an unfulfilled case. +/// `Other` must be nonblank, at most 1024 UTF-8 bytes, and contain no NUL. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TicketCompletion { - pub expected_content_digest: String, - pub expected_state: TicketWorkflowState, - pub operation_key: String, - pub reason: String, - #[serde(default)] - pub references: Vec, - #[serde(default)] - pub author: Option, +#[serde(rename_all = "snake_case")] +pub enum TicketClosureReason { + Completed, + NotPlanned, + Duplicate, + Unknown, + Other(String), } -/// External state mutation with content/state CAS and an immutable operation receipt. -/// Exact replay returns the current authoritative Ticket without a new event or -/// reapplying the recorded state, even after later edits or reopening. -/// Setting `state` to Closed also stores `reason` as the resolution; reopening -/// changes Ticket metadata only and never revives released work identities. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TicketStateUpdate { - pub expected_content_digest: String, - pub expected_state: TicketWorkflowState, - pub state: TicketWorkflowState, - pub operation_key: String, - pub reason: String, - #[serde(default)] - pub references: Vec, - #[serde(default)] - pub author: Option, +/// Close requires an open case; reopen and correction require a closed case. +/// Reopen clears the current judgment but retains its history and last closure key. +/// Correction appends a new judgment without rewriting the old one. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum TicketCaseAction { + Close { reason: TicketClosureReason }, + Reopen, + CorrectClosure { reason: TicketClosureReason }, } -impl From for TicketStateUpdate { - fn from(request: TicketCompletion) -> Self { - Self { - expected_content_digest: request.expected_content_digest, - expected_state: request.expected_state, - state: TicketWorkflowState::Done, - operation_key: request.operation_key, - reason: request.reason, - references: request.references, - author: request.author, +// Serde's internally tagged unit variants ignore surplus keys despite +// deny_unknown_fields. Decode Reopen as an empty struct to reject hidden reasons. +impl<'de> Deserialize<'de> for TicketCaseAction { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + #[derive(Deserialize)] + #[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] + enum Input { + Close { reason: TicketClosureReason }, + Reopen {}, + CorrectClosure { reason: TicketClosureReason }, } + Ok(match Input::deserialize(deserializer)? { + Input::Close { reason } => Self::Close { reason }, + Input::Reopen {} => Self::Reopen, + Input::CorrectClosure { reason } => Self::CorrectClosure { reason }, + }) } } +fn deserialize_case_closure_observation<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + Option::::deserialize(deserializer) +} + +/// Common content/state/closure-key CAS. Exact replay returns current authority, +/// without reapplying old state. The last closure key survives reopening for ABA protection. +/// Every action needs an explanation; typed references are optional. The host sets +/// author/source from authenticated authority, never caller-supplied role claims. +/// No repository, MR, worker lifecycle or approval prerequisite is imposed here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketCaseUpdate { + pub expected_content_digest: String, + pub expected_state: TicketState, + /// Required wire field: send explicit null when no closure has been observed. + #[serde(deserialize_with = "deserialize_case_closure_observation")] + #[schemars(required)] + pub expected_closure_operation_key: Option, + pub operation_key: String, + pub action: TicketCaseAction, + pub explanation: String, + #[serde(default)] + pub references: Vec, + #[serde(default)] + pub author: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketClosureJudgment { + pub operation_key: String, + pub reason: TicketClosureReason, + pub explanation: String, + pub references: Vec, + pub author: String, + pub at: String, +} + +/// Immutable case decision, including reopening and corrections. A correction +/// adds a new judgment; it never overwrites the old reason, actor or references. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketCaseHistoryEntry { + pub operation_key: String, + pub previous_closure_operation_key: Option, + pub action: TicketCaseAction, + pub explanation: String, + pub references: Vec, + pub author: String, + pub at: String, +} + +/// Original migration snapshot for downstream routing/tracking handoff only. +/// This is not a second current state authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketLegacyWorkflowSnapshot { + pub workflow_state: String, + pub workflow_state_explicit: bool, + pub status: String, + pub queued_by: Option, + pub queued_at: Option, + pub resolution: Option, +} + /// SHA-256 digest comparing Ticket identity, title, body, and ordered Repository targets. -/// Comments, timestamps, workflow state, and event order are not requirement content. -/// Returning to identical content restores the digest, so state comparison conditions and operation receipts remain separately necessary. +/// Comments, timestamps, case judgments, classification tags, and event order are not requirements. +/// Restoring identical content restores its digest; state CAS and operation receipts remain necessary. pub fn ticket_content_digest(ticket: &Ticket) -> String { content_digest( &ticket.meta.id, @@ -605,7 +682,7 @@ pub struct NewTicket { pub assignee: Option, pub readiness: Option, pub risk_flags: Vec, - pub workflow_state: Option, + pub workflow_state: Option, pub queued_by: Option, pub queued_at: Option, #[serde(default)] @@ -792,6 +869,7 @@ fn encoded_resolved_targets(targets: &[ResolvedTicketTarget]) -> Result .map_err(|error| TicketError::Conflict(format!("failed to encode Ticket targets: {error}"))) } +/// Frozen schema6 routing receipt fingerprint, retained only for exact historical replay. fn mark_ready_fingerprint_v1( ticket: &Ticket, request: &TicketMarkReady, @@ -831,6 +909,8 @@ fn mark_ready_fingerprint_v1( .collect() } +/// Frozen schema7 routing receipt fingerprint. Legacy phase spellings below are +/// fingerprint material only; mark_ready changes readiness, not case state. fn mark_ready_fingerprint( ticket: &Ticket, request: &TicketMarkReady, @@ -910,28 +990,8 @@ fn validate_mark_ready_replay(ticket: &Ticket, request: &TicketMarkReady) -> Res operation_key: request.operation_key.clone(), }); } - if ticket.meta.workflow_state != TicketWorkflowState::Ready - || ticket.meta.targets != canonical_ticket_targets(&targets) - { - return Err(TicketError::StaleWorkflowState { - expected: TicketWorkflowState::Ready.as_str().to_owned(), - actual: ticket.meta.workflow_state.as_str().to_owned(), - }); - } - Ok(true) -} -fn validate_generic_state_change( - current: TicketWorkflowState, - to: TicketWorkflowState, -) -> Result<()> { - if current == to { - return Err(TicketError::InvalidWorkflowTransition { - from: current.as_str().to_owned(), - to: to.as_str().to_owned(), - }); - } - Ok(()) + Ok(true) } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -1054,56 +1114,35 @@ pub struct TicketQueueOutcome { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TicketListState { - Planning, - Ready, - Queued, - InProgress, - Done, + Open, Closed, } impl TicketListState { pub fn parse(value: &str) -> Option { match value { - "planning" => Some(Self::Planning), - "ready" => Some(Self::Ready), - "queued" => Some(Self::Queued), - "inprogress" => Some(Self::InProgress), - "done" => Some(Self::Done), + "open" => Some(Self::Open), "closed" => Some(Self::Closed), _ => None, } } - pub fn as_str(self) -> &'static str { match self { - Self::Planning => "planning", - Self::Ready => "ready", - Self::Queued => "queued", - Self::InProgress => "inprogress", - Self::Done => "done", + Self::Open => "open", Self::Closed => "closed", } } - - fn matches_workflow_state(self, state: TicketWorkflowState) -> bool { - match self { - Self::Planning => state == TicketWorkflowState::Planning, - Self::Ready => state == TicketWorkflowState::Ready, - Self::Queued => state == TicketWorkflowState::Queued, - Self::InProgress => state == TicketWorkflowState::InProgress, - Self::Done => state == TicketWorkflowState::Done, - Self::Closed => state == TicketWorkflowState::Closed, - } + fn matches_workflow_state(self, state: TicketState) -> bool { + self.as_str() == state.as_str() } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TicketStateSelector { - /// All non-closed workflow states: planning, ready, queued, inprogress, and done. + /// Open cases, not necessarily ready to start work. Active, - /// Every workflow state, including closed. + /// Every case, including closed. All, /// An explicit set of list-query state tokens. States(BTreeSet), @@ -1149,9 +1188,9 @@ impl TicketListQuery { } } - pub fn matches_state(&self, state: TicketWorkflowState) -> bool { + pub fn matches_state(&self, state: TicketState) -> bool { match &self.state { - TicketStateSelector::Active => state != TicketWorkflowState::Closed, + TicketStateSelector::Active => state != TicketState::Closed, TicketStateSelector::All => true, TicketStateSelector::States(states) => states .iter() @@ -1268,7 +1307,7 @@ pub struct TicketRelationBlocker { pub reason_kind: String, pub relation_kind: TicketRelationKind, pub note: Option, - pub blocking_state: TicketWorkflowState, + pub blocking_state: TicketState, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -1315,7 +1354,8 @@ pub enum TicketWorkspaceRowKind { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketWorkspaceStateOverlay { pub source: String, - pub workflow_state: TicketWorkflowState, + /// Case state only; field name retained temporarily, never execution progress. + pub workflow_state: TicketState, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -1345,7 +1385,7 @@ pub fn project_ticket_workspace_item( ) -> TicketWorkspaceProjection { let visible_overlay = orchestration_overlay .filter(|overlay| { - ticket_overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state) + ticket_overlay_case_differs(summary.workflow_state, overlay.workflow_state) }) .cloned(); let mut projection = derive_ticket_workspace_projection(summary, relation_blockers); @@ -1364,13 +1404,13 @@ pub fn project_ticket_workspace_item( } pub fn ticket_queue_guard( - _summary: &TicketSummary, + summary: &TicketSummary, relation_blockers: &[TicketRelationBlocker], _orchestration_overlay: Option<&TicketWorkspaceStateOverlay>, ) -> TicketQueueGuard { TicketQueueGuard { - can_queue_for_orchestrator: true, - reason: Some("Queue submits only the explicitly requested Ticket; state and dependencies are diagnostic context".to_owned()), + can_queue_for_orchestrator: summary.workflow_state == TicketState::Open, + reason: Some(if summary.workflow_state == TicketState::Open { "Queue submits only the explicitly requested open case; dependencies are diagnostic context" } else { "Closed case must be explicitly reopened before queueing" }.to_owned()), blocked_reason: (!relation_blockers.is_empty()).then(|| format_relation_blockers(relation_blockers)), } } @@ -1381,78 +1421,39 @@ fn derive_ticket_workspace_projection( ) -> TicketWorkspaceProjection { TicketWorkspaceProjection { kind: workspace_row_kind_for_state(summary.workflow_state), - priority: if matches!( - summary.workflow_state, - TicketWorkflowState::Queued | TicketWorkflowState::InProgress - ) { - TicketWorkspaceActionPriority::ActiveWork - } else { - TicketWorkspaceActionPriority::ReadyForQueue - }, - next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator), + // Cases do not encode execution readiness or routing demand. + priority: TicketWorkspaceActionPriority::Background, + next_action: None, visible_state: summary.workflow_state.to_string(), visible_overlay: None, - disabled_reason: None, - key_hint: Some(format!("Queue only {} for Orchestrator", summary.id)), + disabled_reason: (summary.workflow_state == TicketState::Closed) + .then(|| "Closed case; reopen explicitly before queueing".to_owned()), + key_hint: None, blocked_reason: (!relation_blockers.is_empty()) .then(|| format_relation_blockers(relation_blockers)), queue_guard: ticket_queue_guard(summary, relation_blockers, None), } } -fn workspace_row_kind_for_state(state: TicketWorkflowState) -> TicketWorkspaceRowKind { +fn workspace_row_kind_for_state(state: TicketState) -> TicketWorkspaceRowKind { match state { - TicketWorkflowState::Planning => TicketWorkspaceRowKind::Planning, - TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { - TicketWorkspaceRowKind::ActiveWork - } - TicketWorkflowState::Done | TicketWorkflowState::Closed => TicketWorkspaceRowKind::Review, - TicketWorkflowState::Ready => TicketWorkspaceRowKind::Ticket, + TicketState::Open => TicketWorkspaceRowKind::Ticket, + TicketState::Closed => TicketWorkspaceRowKind::Review, } } fn apply_workspace_overlay_to_projection( projection: &mut TicketWorkspaceProjection, - local: TicketWorkflowState, + local: TicketState, overlay: &TicketWorkspaceStateOverlay, ) { - projection.next_action = Some(TicketWorkspaceNextAction::WaitForOrchestrator); - let overlay_state = overlay.workflow_state.as_str(); - match overlay.workflow_state { - TicketWorkflowState::Done | TicketWorkflowState::Closed => { - projection.kind = TicketWorkspaceRowKind::Review; - projection.priority = TicketWorkspaceActionPriority::Background; - projection.disabled_reason = Some(format!( - "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.", - overlay.source, - local.as_str() - )); - projection.key_hint = Some(format!( - "Merge pending: local: {} · {}: {overlay_state}", - local.as_str(), - overlay.source - )); - } - TicketWorkflowState::InProgress | TicketWorkflowState::Queued => { - projection.kind = TicketWorkspaceRowKind::ActiveWork; - projection.priority = TicketWorkspaceActionPriority::ActiveWork; - projection.disabled_reason = Some(format!( - "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.", - overlay.source, - local.as_str() - )); - projection.key_hint = Some(format!( - "Progress overlay: local: {} · {}: {overlay_state}", - local.as_str(), - overlay.source - )); - } - TicketWorkflowState::Planning | TicketWorkflowState::Ready => {} - } + // Case state overlays cannot imply worker execution or merge progress. + projection.visible_state = + format!("{} ({}: {})", local, overlay.source, overlay.workflow_state); } fn ticket_workspace_state_display( - local: TicketWorkflowState, + local: TicketState, overlay: Option<&TicketWorkspaceStateOverlay>, ) -> String { match overlay { @@ -1465,33 +1466,12 @@ fn ticket_workspace_state_display( } } -fn ticket_overlay_state_has_progressed( - local: TicketWorkflowState, - overlay: TicketWorkflowState, -) -> bool { - workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local) +fn ticket_overlay_case_differs(local: TicketState, overlay: TicketState) -> bool { + local != overlay } -fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 { - match state { - TicketWorkflowState::Planning => 0, - TicketWorkflowState::Ready => 1, - TicketWorkflowState::Queued => 2, - TicketWorkflowState::InProgress => 3, - TicketWorkflowState::Done => 4, - TicketWorkflowState::Closed => 5, - } -} - -fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str { - match state { - TicketWorkflowState::Planning => "plan", - TicketWorkflowState::Ready => "ready", - TicketWorkflowState::Queued => "q", - TicketWorkflowState::InProgress => "prog", - TicketWorkflowState::Done => "done", - TicketWorkflowState::Closed => "cls", - } +fn compact_ticket_state_label(state: TicketState) -> &'static str { + state.as_str() } #[derive( @@ -1619,7 +1599,8 @@ pub struct TicketMeta { pub assignee: Option, pub readiness: Option, pub risk_flags: Vec, - pub workflow_state: TicketWorkflowState, + /// Case state only; field name retained temporarily, never execution progress. + pub workflow_state: TicketState, pub workflow_state_explicit: bool, pub queued_by: Option, pub queued_at: Option, @@ -1630,6 +1611,8 @@ pub struct TicketMeta { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketSummary { + #[serde(default)] + pub tags: Vec, pub id: String, #[serde(default, skip_serializing_if = "Option::is_none")] pub resource_key: Option, @@ -1640,7 +1623,8 @@ pub struct TicketSummary { pub priority: String, pub labels: Vec, pub readiness: Option, - pub workflow_state: TicketWorkflowState, + /// Case state only; field name retained temporarily, never execution progress. + pub workflow_state: TicketState, pub workflow_state_explicit: bool, pub queued_by: Option, pub queued_at: Option, @@ -1671,7 +1655,7 @@ pub struct SqliteTicketListCursor { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct SqliteTicketListPageQuery { - pub states: Vec, + pub states: Vec, pub limit: usize, pub after: Option, } @@ -1713,12 +1697,19 @@ pub struct TicketArtifactRef { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct Ticket { + #[serde(default)] + pub tags: Vec, pub meta: TicketMeta, pub document: TicketDocument, pub events: Vec, pub artifacts: Vec, pub relations: TicketRelationView, pub resolution: Option, + /// Last closure/correction operation key; retained when reopened (ABA guard). + pub closure_operation_key: Option, + pub current_closure: Option, + pub case_history: Vec, + pub legacy_workflow_snapshot: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -1769,6 +1760,13 @@ impl TicketDoctorReport { } pub trait TicketBackend { + fn list_workspace_tags(&self, request: TagListRequest) -> Result>; + fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result; + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: TicketTagSetRequest, + ) -> Result>; fn default_intake_ready_state_change_body(&self, from: &str) -> String; fn list(&self, filter: TicketListQuery) -> Result>; fn show(&self, id: TicketIdOrSlug) -> Result; @@ -1792,36 +1790,19 @@ pub trait TicketBackend { fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>; fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>; - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> Result<()>; - fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; - /// State update comparing requirement content and workflow state in the same write transaction. - /// Compares both even when only requirements change, preventing late decisions based on old content from committing. - /// Saves state, status, resolution, and the audit event together; the event is an immutable operation receipt. + /// Records a reasoned close, reopen, or closure correction independently of Repository integration. + /// Compares requirement content, case state, and the observed nullable closure key in one write + /// transaction, saving state, judgment history, and the immutable operation receipt together. + /// Host work-release hooks participate in that transaction; failure rolls back the judgment. /// - /// Exact replay returns the current Ticket even after later edits or reopening. - /// Does not reapply past state or append events. Rejects different input with the same operation key. - /// Updating to Closed also stores the reason as resolution. Reopening updates Ticket metadata; - /// it does not revive released work identities. - fn update_state(&self, _ticket: &str, _request: TicketStateUpdate) -> Result { - Err(TicketError::Conflict( - "backend does not support audited state updates".to_owned(), - )) - } - /// Ticket completion decision with a reason and references, independent of Repository integration. - /// Uses the same content/state comparison conditions and operation receipts as [`Self::update_state`]. - /// Requires neither MR nor Reviewer approval; completion neither creates nor changes their evidence. - /// Missing MR evidence on the read side is not grounds to revoke a recorded completion decision. - fn complete(&self, ticket: &str, request: TicketCompletion) -> Result { - self.update_state(ticket, request.into()) - } + /// Exact replay returns the current Ticket without reapplying old state or appending history. + /// Reusing a key with different input or authenticated source identity conflicts. + /// No universal MR or Reviewer approval gate applies; explicit user conditions still govern + /// judgment, and this operation neither creates nor revokes MR evidence. Missing read-side + /// evidence does not undo a recorded judgment. Reopening never revives released work identities. + fn update_case(&self, ticket: &str, request: TicketCaseUpdate) -> Result; fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result; fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result; - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -1855,6 +1836,16 @@ pub trait TicketBackend { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "operation", rename_all = "snake_case")] pub enum TicketBackendOperation { + ListWorkspaceTags { + request: TagListRequest, + }, + MutateWorkspaceTag { + request: WorkspaceTagMutation, + }, + SetTicketTags { + id: TicketIdOrSlug, + request: TicketTagSetRequest, + }, DefaultIntakeReadyStateChangeBody { from: String, }, @@ -1886,22 +1877,10 @@ pub enum TicketBackendOperation { id: TicketIdOrSlug, summary: TicketIntakeSummary, }, - SetStateField { - id: TicketIdOrSlug, - field: String, - change: TicketStateChange, - }, - SetWorkflowState { - id: TicketIdOrSlug, - change: TicketStateChange, - }, - UpdateState { + + UpdateCase { ticket: String, - request: TicketStateUpdate, - }, - Complete { - ticket: String, - request: TicketCompletion, + request: TicketCaseUpdate, }, MarkReady { id: TicketIdOrSlug, @@ -1911,10 +1890,7 @@ pub enum TicketBackendOperation { id: TicketIdOrSlug, queued_by: String, }, - Close { - id: TicketIdOrSlug, - resolution: MarkdownText, - }, + AddTicketRelation { id: TicketIdOrSlug, relation: NewTicketRelation, @@ -1945,6 +1921,8 @@ pub enum TicketBackendOperation { #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "result", content = "value", rename_all = "snake_case")] pub enum TicketBackendOperationResult { + WorkspaceTags(Vec), + WorkspaceTag(WorkspaceTag), Text(String), Unit, Tickets(Vec), @@ -1968,6 +1946,15 @@ where B: TicketBackend + ?Sized, { Ok(match operation { + TicketBackendOperation::ListWorkspaceTags { request } => { + TicketBackendOperationResult::WorkspaceTags(backend.list_workspace_tags(request)?) + } + TicketBackendOperation::MutateWorkspaceTag { request } => { + TicketBackendOperationResult::WorkspaceTag(backend.mutate_workspace_tag(request)?) + } + TicketBackendOperation::SetTicketTags { id, request } => { + TicketBackendOperationResult::WorkspaceTags(backend.set_ticket_tags(id, request)?) + } TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => { TicketBackendOperationResult::Text( backend.default_intake_ready_state_change_body(&from), @@ -2000,19 +1987,9 @@ where backend.add_intake_summary(id, summary)?; TicketBackendOperationResult::Unit } - TicketBackendOperation::SetStateField { id, field, change } => { - backend.set_state_field(id, &field, change)?; - TicketBackendOperationResult::Unit - } - TicketBackendOperation::SetWorkflowState { id, change } => { - backend.set_workflow_state(id, change)?; - TicketBackendOperationResult::Unit - } - TicketBackendOperation::UpdateState { ticket, request } => { - TicketBackendOperationResult::Ticket(backend.update_state(&ticket, request)?) - } - TicketBackendOperation::Complete { ticket, request } => { - TicketBackendOperationResult::Ticket(backend.complete(&ticket, request)?) + + TicketBackendOperation::UpdateCase { ticket, request } => { + TicketBackendOperationResult::Ticket(backend.update_case(&ticket, request)?) } TicketBackendOperation::MarkReady { id, request } => { TicketBackendOperationResult::Ticket(backend.mark_ready(id, request)?) @@ -2020,10 +1997,7 @@ where TicketBackendOperation::QueueReady { id, queued_by } => { TicketBackendOperationResult::QueueOutcome(backend.queue_ready(id, &queued_by)?) } - TicketBackendOperation::Close { id, resolution } => { - backend.close(id, resolution)?; - TicketBackendOperationResult::Unit - } + TicketBackendOperation::AddTicketRelation { id, relation } => { TicketBackendOperationResult::Relation(backend.add_ticket_relation(id, relation)?) } @@ -2233,20 +2207,14 @@ impl SqliteTicketBackend { )) AND (?3 IS NULL OR CASE ticket.workflow_state - WHEN 'ready' THEN 0 WHEN 'planning' THEN 1 - WHEN 'inprogress' THEN 2 WHEN 'queued' THEN 3 - WHEN 'done' THEN 4 WHEN 'closed' THEN 5 ELSE 6 END > ?4 + WHEN 'open' THEN 0 WHEN 'closed' THEN 1 ELSE 2 END > ?4 OR (CASE ticket.workflow_state - WHEN 'ready' THEN 0 WHEN 'planning' THEN 1 - WHEN 'inprogress' THEN 2 WHEN 'queued' THEN 3 - WHEN 'done' THEN 4 WHEN 'closed' THEN 5 ELSE 6 END = ?4 + WHEN 'open' THEN 0 WHEN 'closed' THEN 1 ELSE 2 END = ?4 AND (COALESCE(ticket.updated_at, '') < COALESCE(?5, '') OR (COALESCE(ticket.updated_at, '') = COALESCE(?5, '') AND ticket.ticket_id > ?3)))) ORDER BY CASE ticket.workflow_state - WHEN 'ready' THEN 0 WHEN 'planning' THEN 1 - WHEN 'inprogress' THEN 2 WHEN 'queued' THEN 3 - WHEN 'done' THEN 4 WHEN 'closed' THEN 5 ELSE 6 END ASC, + WHEN 'open' THEN 0 WHEN 'closed' THEN 1 ELSE 2 END ASC, ticket.updated_at DESC, ticket.ticket_id ASC LIMIT ?6", ) @@ -2269,20 +2237,14 @@ impl SqliteTicketBackend { .map_err(sqlite_err)?; for summary in &mut summaries { summary.resource_key = Self::resource_key_for(conn, &self.workspace_id, &summary.id)?; + summary.tags = self.load_ticket_tags(conn, &summary.id)?; } let has_more = summaries.len() > query.limit; summaries.truncate(query.limit); let next = has_more.then(|| { let summary = summaries.last().expect("non-empty page with continuation"); SqliteTicketListCursor { - state_rank: match summary.workflow_state { - TicketWorkflowState::Ready => 0, - TicketWorkflowState::Planning => 1, - TicketWorkflowState::InProgress => 2, - TicketWorkflowState::Queued => 3, - TicketWorkflowState::Done => 4, - TicketWorkflowState::Closed => 5, - }, + state_rank: match summary.workflow_state { TicketState::Open => 0, TicketState::Closed => 1 }, updated_at: summary.updated_at.clone(), ticket_id: summary.id.clone(), } @@ -2353,6 +2315,7 @@ impl SqliteTicketBackend { updated_at, ) = row.map_err(sqlite_err)?; summaries.push(TicketSummary { + tags: self.load_ticket_tags(conn, &id)?, resource_key: Self::resource_key_for(conn, &self.workspace_id, &id)?, id, slug, @@ -2362,8 +2325,9 @@ impl SqliteTicketBackend { priority, labels: Vec::new(), readiness, - workflow_state: TicketWorkflowState::parse(&workflow_state) - .unwrap_or(TicketWorkflowState::Planning), + workflow_state: TicketState::parse(&workflow_state).ok_or_else(|| { + TicketError::Sqlite(format!("invalid Ticket case state {workflow_state:?}")) + })?, workflow_state_explicit, queued_by, queued_at, @@ -2426,6 +2390,16 @@ impl SqliteTicketBackend { { return Ok(()); } + if self + .event_attributes + .get("source_actor_role") + .map(String::as_str) + != Some("ticket_worker") + { + return Err(TicketError::Conflict( + "source assignment requires authenticated ticket_worker role".into(), + )); + } let required = |key: &str| { self.event_attributes .get(key) @@ -2450,168 +2424,19 @@ impl SqliteTicketBackend { Ok(()) } - fn state_update_fingerprint( - &self, - ticket_id: &str, - request: &TicketStateUpdate, - body: &MarkdownText, - ) -> Result { - let source = [ - "source_runtime_id", - "source_worker_id", - "source_actor_role", - "source_assignment_id", - ] - .map(|key| self.event_attributes.get(key)); - let mut digest = Sha256::new(); - digest.update(b"ticket.state-update.v2\0"); - digest.update( - serde_json::to_vec(&(ticket_id, request, body, source)) - .map_err(|error| TicketError::Conflict(error.to_string()))?, - ); - Ok(digest - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect()) - } - - fn next_legacy_state_operation_key( + fn insert_case_history( &self, conn: &Connection, ticket_id: &str, - ) -> Result { - let index: i64 = conn.query_row( - "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2", - params![self.workspace_id, ticket_id], |row| row.get(0), - ).map_err(sqlite_err)?; - Ok(format!("legacy-state:{ticket_id}:{index}")) - } - - /// All callers supply a snapshot read inside this same write transaction. - /// Legacy adapters retain their Markdown bodies while sharing CAS, source - /// authorization, state/status/resolution writes, audit, and operation receipts. - fn apply_state_update( - &self, - conn: &Connection, - previous: Ticket, - request: TicketStateUpdate, - body: MarkdownText, - ) -> Result { - validate_required_event_value("operation_key", &request.operation_key)?; - validate_required_event_value("expected_content_digest", &request.expected_content_digest)?; - validate_state_change(&TicketStateChange { - from: request.expected_state.to_string(), - to: request.state.to_string(), - author: request.author.clone(), - reason: request.reason.clone(), - body: body.clone(), - references: request.references.clone(), - })?; - let ticket_id = &previous.meta.id; - let fingerprint = self.state_update_fingerprint(ticket_id, &request, &body)?; - if let Some(event) = previous - .events - .iter() - .find(|event| event.attributes.get("operation_key") == Some(&request.operation_key)) - { - if event - .attributes - .get("fingerprint_version") - .map(String::as_str) - != Some("state-update-v2") - || event.attributes.get("request_fingerprint") != Some(&fingerprint) - { - return Err(TicketError::OperationFingerprintMismatch { - operation_key: request.operation_key.clone(), - }); - } - // The immutable event is the receipt; replay returns current authority - // without appending an event or reapplying the receipt's old state. - return Ok(previous); + entry: &TicketCaseHistoryEntry, + fingerprint: &str, + ) -> Result<()> { + let json = serde_json::to_string(entry).map_err(sqlite_err)?; + conn.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) SELECT ?1, ?2, COALESCE(MAX(ordinal), -1) + 1, ?3, ?4, ?5 FROM typed_ticket_case_history WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, entry.operation_key, fingerprint, json]).map_err(sqlite_err)?; + if !matches!(entry.action, TicketCaseAction::Reopen) { + conn.execute("INSERT INTO typed_ticket_case_metadata (workspace_id, ticket_id, closure_operation_key) VALUES (?1, ?2, ?3) ON CONFLICT (workspace_id, ticket_id) DO UPDATE SET closure_operation_key = excluded.closure_operation_key", params![self.workspace_id, ticket_id, entry.operation_key]).map_err(sqlite_err)?; } - // Exact recorded replay is independent of today's live role/assignment. - // Parent transport authenticates identity before restoring receipt context. - self.require_active_source_assignment(conn, ticket_id)?; - // Compare content and workflow state before any state/audit write, using - // the snapshot read in this same write transaction. Acceptance criteria - // may change without a state change, so a late completion must not win - // solely by arrival order. Equal timestamps do not prove equal content. - let actual = ticket_content_digest(&previous); - if actual != request.expected_content_digest { - return Err(TicketError::StaleContent { - expected: request.expected_content_digest.clone(), - actual, - }); - } - if previous.meta.workflow_state != request.expected_state { - return Err(TicketError::StaleWorkflowState { - expected: request.expected_state.to_string(), - actual: previous.meta.workflow_state.to_string(), - }); - } - let at = now_utc(); - let updated = conn.execute( - "UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, status = CASE WHEN ?3 = 'closed' THEN 'closed' ELSE 'open' END, updated_at = ?4, resolution = CASE WHEN ?3 = 'closed' THEN ?5 ELSE resolution END WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = ?6", - params![self.workspace_id, ticket_id, request.state.as_str(), at, body.as_str(), request.expected_state.as_str()], - ).map_err(sqlite_err)?; - if updated != 1 { - return Err(TicketError::Conflict( - "Ticket changed during state mutation".to_owned(), - )); - } - let kind = if request.state == TicketWorkflowState::Closed { - TicketEventKind::Close - } else { - TicketEventKind::StateChanged - }; - self.insert_event( - conn, - ticket_id, - &TicketEvent { - heading: Some(kind.heading()), - kind, - author: Some(request.author.clone().unwrap_or_else(default_author)), - at: Some(at), - status: Some( - if request.state == TicketWorkflowState::Closed { - "closed" - } else { - "open" - } - .to_owned(), - ), - from: Some(request.expected_state.to_string()), - to: Some(request.state.to_string()), - reason: Some(request.reason.clone()), - state_field: Some("state".to_owned()), - body, - references: request.references.clone(), - attributes: BTreeMap::from([ - ("operation_key".to_owned(), request.operation_key.clone()), - ("request_fingerprint".to_owned(), fingerprint), - ( - "fingerprint_version".to_owned(), - "state-update-v2".to_owned(), - ), - ( - "expected_content_digest".to_owned(), - request.expected_content_digest.clone(), - ), - ]), - }, - )?; - let result = self.load_ticket(conn, ticket_id)?; - if !result - .events - .iter() - .any(|event| event.attributes.get("operation_key") == Some(&request.operation_key)) - { - return Err(TicketError::Conflict( - "state update audit lost its operation key".to_owned(), - )); - } - Ok(result) + Ok(()) } fn with_read(&self, op: impl FnOnce(&Connection) -> Result) -> Result { @@ -2914,8 +2739,7 @@ impl SqliteTicketBackend { assignee: row.get(8)?, readiness: row.get(9)?, risk_flags: Vec::new(), - workflow_state: TicketWorkflowState::parse(&state_raw) - .unwrap_or(TicketWorkflowState::Planning), + workflow_state: parse_sql_case_state(&state_raw, 12)?, workflow_state_explicit: row.get::<_, i64>(13)? != 0, queued_by: row.get(14)?, queued_at: row.get(15)?, @@ -2938,7 +2762,49 @@ impl SqliteTicketBackend { let events = self.load_events(conn, ticket_id)?; let artifacts = self.load_artifacts(conn, ticket_id)?; let relations = self.relation_view_for_meta(conn, &meta)?; + let closure_operation_key = conn.query_row( + "SELECT closure_operation_key FROM typed_ticket_case_metadata WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket_id], |row| row.get::<_, Option>(0), + ).optional().map_err(sqlite_err)?.flatten(); + let mut statement = conn.prepare("SELECT judgment_json FROM typed_ticket_case_history WHERE workspace_id = ?1 AND ticket_id = ?2 ORDER BY ordinal").map_err(sqlite_err)?; + let rows = statement + .query_map(params![self.workspace_id, ticket_id], |row| { + row.get::<_, String>(0) + }) + .map_err(sqlite_err)?; + let mut case_history = Vec::::new(); + for row in rows { + case_history.push(serde_json::from_str(&row.map_err(sqlite_err)?).map_err(sqlite_err)?); + } + let current_closure = if meta.workflow_state == TicketState::Closed { + case_history + .iter() + .rev() + .find_map(|entry| match &entry.action { + TicketCaseAction::Close { reason } + | TicketCaseAction::CorrectClosure { reason } => Some(TicketClosureJudgment { + operation_key: entry.operation_key.clone(), + reason: reason.clone(), + explanation: entry.explanation.clone(), + references: entry.references.clone(), + author: entry.author.clone(), + at: entry.at.clone(), + }), + TicketCaseAction::Reopen => None, + }) + } else { + None + }; + let legacy_json = conn.query_row("SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id], |row| row.get::<_, String>(0)).optional().map_err(sqlite_err)?; + let legacy_workflow_snapshot = legacy_json + .map(|json| serde_json::from_str(&json).map_err(sqlite_err)) + .transpose()?; Ok(Ticket { + closure_operation_key, + current_closure, + case_history, + legacy_workflow_snapshot, + tags: self.load_ticket_tags(conn, ticket_id)?, meta, document: TicketDocument { body: MarkdownText::new(body), @@ -3108,12 +2974,14 @@ impl SqliteTicketBackend { } meta.labels = self.load_ordered_values(conn, "typed_ticket_labels", "label", &meta.id)?; - summaries.push(ticket_summary_from_meta(meta)); + let mut summary = ticket_summary_from_meta(meta); + summary.tags = self.load_ticket_tags(conn, &summary.id)?; + summaries.push(summary); } Ok(summaries) } - fn state_index(&self, conn: &Connection) -> Result> { + fn state_index(&self, conn: &Connection) -> Result> { let mut stmt = conn .prepare("SELECT ticket_id, workflow_state FROM typed_tickets WHERE workspace_id = ?1") .map_err(sqlite_err)?; @@ -3125,10 +2993,7 @@ impl SqliteTicketBackend { let mut states = HashMap::new(); for row in rows { let (id, state) = row.map_err(sqlite_err)?; - states.insert( - id, - TicketWorkflowState::parse(&state).unwrap_or(TicketWorkflowState::Planning), - ); + states.insert(id, TicketState::parse(&state).unwrap_or(TicketState::Open)); } Ok(states) } @@ -3183,11 +3048,24 @@ fn finish_sqlite_transaction(conn: &Connection, result: Result) -> Result< } impl TicketBackend for SqliteTicketBackend { + fn list_workspace_tags(&self, request: TagListRequest) -> Result> { + self.list_tags(request) + } + fn mutate_workspace_tag(&self, request: WorkspaceTagMutation) -> Result { + self.mutate_tag(request) + } + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: TicketTagSetRequest, + ) -> Result> { + self.set_tags(id, request) + } fn default_intake_ready_state_change_body(&self, from: &str) -> String { if is_japanese_record_language(self.record_language()) { - format!("Ticket planning が完了しました。state {from} -> ready。\n") + format!("Ticket のrouting readinessをreadyにしました。case stateは{from}のままです。\n") } else { - format!("Ticket planning complete; state {from} -> ready.\n") + format!("Ticket routing readiness set to ready; case remains {from}.\n") } } @@ -3225,10 +3103,8 @@ impl TicketBackend for SqliteTicketBackend { TicketError::Conflict(format!("failed to allocate unique ticket id: {err}")) })?; let now = now_utc(); - let state = input - .workflow_state - .unwrap_or(TicketWorkflowState::Planning); - let status = if state == TicketWorkflowState::Closed { + let state = input.workflow_state.unwrap_or(TicketState::Open); + let status = if state == TicketState::Closed { ExtensibleTicketStatus::Closed } else { ExtensibleTicketStatus::Open @@ -3263,6 +3139,11 @@ impl TicketBackend for SqliteTicketBackend { .author .unwrap_or_else(|| "SqliteTicketBackend".to_string()); let ticket = Ticket { + closure_operation_key: None, + current_closure: None, + case_history: Vec::new(), + legacy_workflow_snapshot: None, + tags: Vec::new(), meta, document: TicketDocument { body, @@ -3270,7 +3151,7 @@ impl TicketBackend for SqliteTicketBackend { }, events: vec![TicketEvent { kind: TicketEventKind::Create, - author: Some(author), + author: Some(author.clone()), at: Some(now.clone()), status: None, from: None, @@ -3288,11 +3169,30 @@ impl TicketBackend for SqliteTicketBackend { }; let resource_key = Self::allocate_resource_key(conn, &self.workspace_id, &id, &now)?; self.insert_ticket(conn, &ticket)?; + if state == TicketState::Closed { + let entry = TicketCaseHistoryEntry { + operation_key: format!("create-closed:{id}"), + previous_closure_operation_key: None, + action: TicketCaseAction::Close { + reason: TicketClosureReason::Unknown, + }, + explanation: "Created as a closed case; no typed closure reason was supplied." + .into(), + references: Vec::new(), + author, + at: now.clone(), + }; + self.insert_case_history(conn, &id, &entry, "initial-closed-v9")?; + } let reference = TicketRef { id: id.clone(), resource_key: Some(resource_key), slug: id.clone(), - status: TicketStatus::Open, + status: if state == TicketState::Closed { + TicketStatus::Closed + } else { + TicketStatus::Open + }, }; let persisted = self.load_ticket(conn, &id)?; Ok((reference, persisted)) @@ -3410,7 +3310,8 @@ impl TicketBackend for SqliteTicketBackend { let states = self.state_index(conn)?; let relations = self.all_relations(conn)?; let blockers = transitive_dependency_blockers(&ticket_id, &states, &relations)?; - let summary = ticket_summary_from_meta(ticket.meta); + let mut summary = ticket_summary_from_meta(ticket.meta); + summary.tags = ticket.tags; let mut projection = project_ticket_workspace_item(&summary, &blockers, None); projection.queue_guard = ticket_queue_guard(&summary, &blockers, None); let queue_tickets = vec![ticket_id]; @@ -3452,8 +3353,10 @@ impl TicketBackend for SqliteTicketBackend { }) } - fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { - self.set_workflow_state(id, change) + fn add_state_changed(&self, _id: TicketIdOrSlug, _change: TicketStateChange) -> Result<()> { + Err(TicketError::Conflict( + "case changes require update_case with content/state/closure-key CAS".into(), + )) } fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()> { @@ -3483,57 +3386,72 @@ impl TicketBackend for SqliteTicketBackend { }) } - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> Result<()> { - if field != "state" { - return Err(TicketError::Conflict(format!( - "unsupported Ticket state field: {field}" - ))); + fn update_case(&self, ticket: &str, request: TicketCaseUpdate) -> Result { + validate_case_text("operation_key", &request.operation_key)?; + validate_case_text("expected_content_digest", &request.expected_content_digest)?; + validate_case_text("explanation", &request.explanation)?; + if let Some(key) = &request.expected_closure_operation_key { + validate_case_text("expected_closure_operation_key", key)?; } - self.set_workflow_state(id, change) - } - - fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { - let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| { - TicketError::InvalidWorkflowTransition { - from: change.from.clone(), - to: change.to.clone(), + if let Some(author) = &request.author { + validate_case_text("author", author)?; + } + for reference in &request.references { + validate_case_text("reference_kind", &reference.kind)?; + validate_case_text("reference_target", &reference.target)?; + } + if let TicketCaseAction::Close { + reason: TicketClosureReason::Other(reason), + } + | TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Other(reason), + } = &request.action + { + validate_case_text("custom closure reason", reason)?; + if reason.len() > 1024 { + return Err(TicketError::Conflict( + "custom closure reason exceeds 1024 UTF-8 bytes".into(), + )); } - })?; - let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| { - TicketError::InvalidWorkflowTransition { - from: change.from.clone(), - to: change.to.clone(), - } - })?; - validate_generic_state_change(from, to)?; - self.with_write(|conn| { - let ticket_id = self.resolve_ticket_id(conn, id)?; - let previous = self.load_ticket(conn, &ticket_id)?; - let request = TicketStateUpdate { - expected_content_digest: ticket_content_digest(&previous), - expected_state: from, - state: to, - operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, - reason: change.reason, - references: change.references, - author: Some(change.author.unwrap_or_else(default_author)), - }; - self.apply_state_update(conn, previous, request, change.body) - .map(|_| ()) - }) - } - - fn update_state(&self, ticket: &str, request: TicketStateUpdate) -> Result { + } self.with_write(|conn| { let ticket_id = self.resolve_ticket_id(conn, ticket.into())?; let previous = self.load_ticket(conn, &ticket_id)?; - let body = MarkdownText::new(&request.reason); - self.apply_state_update(conn, previous, request, body) + let source = ["source_runtime_id", "source_worker_id", "source_actor_role", "source_assignment_id"].map(|key| self.event_attributes.get(key)); + let fingerprint_input = serde_json::to_vec(&("ticket.case-update.v1", &ticket_id, &request, source)).map_err(sqlite_err)?; + let fingerprint = Sha256::digest(fingerprint_input).iter().map(|byte| format!("{byte:02x}")).collect::(); + let receipt = conn.query_row("SELECT request_fingerprint FROM typed_ticket_case_history WHERE workspace_id = ?1 AND ticket_id = ?2 AND operation_key = ?3", params![self.workspace_id, ticket_id, request.operation_key], |row| row.get::<_, String>(0)).optional().map_err(sqlite_err)?; + if let Some(receipt) = receipt { + return if receipt == fingerprint { Ok(previous) } else { Err(TicketError::OperationFingerprintMismatch { operation_key: request.operation_key.clone() }) }; + } + // Do not reuse a key already owned by another audited operation. + if previous.events.iter().any(|event| event.attributes.get("operation_key") == Some(&request.operation_key)) { + return Err(TicketError::OperationFingerprintMismatch { operation_key: request.operation_key.clone() }); + } + self.require_active_source_assignment(conn, &ticket_id)?; + let actual = ticket_content_digest(&previous); + if actual != request.expected_content_digest { return Err(TicketError::StaleContent { expected: request.expected_content_digest.clone(), actual }); } + if previous.meta.workflow_state != request.expected_state { return Err(TicketError::StaleWorkflowState { expected: request.expected_state.to_string(), actual: previous.meta.workflow_state.to_string() }); } + if previous.closure_operation_key != request.expected_closure_operation_key { return Err(TicketError::StaleClosureOperationKey { expected: request.expected_closure_operation_key.clone(), actual: previous.closure_operation_key }); } + let state = match &request.action { + TicketCaseAction::Close { .. } if previous.meta.workflow_state == TicketState::Open => TicketState::Closed, + TicketCaseAction::Reopen if previous.meta.workflow_state == TicketState::Closed => TicketState::Open, + TicketCaseAction::CorrectClosure { .. } if previous.meta.workflow_state == TicketState::Closed && previous.current_closure.is_some() => TicketState::Closed, + _ => return Err(TicketError::Conflict("Close requires open; Reopen and CorrectClosure require closed with a current judgment".into())), + }; + let at = now_utc(); + let entry = TicketCaseHistoryEntry { operation_key: request.operation_key.clone(), previous_closure_operation_key: request.expected_closure_operation_key.clone(), action: request.action.clone(), explanation: request.explanation.clone(), references: request.references.clone(), author: request.author.clone().unwrap_or_else(default_author), at: at.clone() }; + self.insert_case_history(conn, &ticket_id, &entry, &fingerprint)?; + conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, status = ?3, updated_at = ?4, resolution = CASE WHEN ?3 = 'closed' THEN ?5 ELSE NULL END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, state.as_str(), at, request.explanation]).map_err(sqlite_err)?; + let kind = if matches!(request.action, TicketCaseAction::Close { .. }) { TicketEventKind::Close } else { TicketEventKind::StateChanged }; + self.insert_event(conn, &ticket_id, &TicketEvent { + heading: Some(kind.heading()), kind, author: Some(entry.author), at: Some(at), status: Some(state.to_string()), from: Some(previous.meta.workflow_state.to_string()), to: Some(state.to_string()), reason: Some(request.explanation.clone()), state_field: Some("case".into()), body: MarkdownText::new(&request.explanation), references: request.references.clone(), + attributes: BTreeMap::from([("operation_key".into(), request.operation_key.clone()), ("fingerprint_version".into(), "case-update-v1".into()), ("request_fingerprint".into(), fingerprint.clone()), ("case_action".into(), serde_json::to_string(&request.action).map_err(sqlite_err)?), ("expected_content_digest".into(), request.expected_content_digest.clone())]), + })?; + let result = self.load_ticket(conn, &ticket_id)?; + let audited = result.events.last().is_some_and(|event| event.attributes.get("operation_key") == Some(&request.operation_key) && event.attributes.get("request_fingerprint") == Some(&fingerprint)); + if !audited { return Err(TicketError::Conflict("Ticket case audit context overwrote its immutable operation receipt".into())); } + Ok(result) }) } @@ -3546,9 +3464,9 @@ impl TicketBackend for SqliteTicketBackend { return Ok(ticket); } self.require_active_source_assignment(conn, &ticket_id)?; - if ticket.meta.workflow_state != TicketWorkflowState::Planning { + if ticket.meta.workflow_state != TicketState::Open { return Err(TicketError::StaleWorkflowState { - expected: TicketWorkflowState::Planning.as_str().to_owned(), + expected: TicketState::Open.as_str().to_owned(), actual: ticket.meta.workflow_state.as_str().to_owned(), }); } @@ -3609,10 +3527,10 @@ impl TicketBackend for SqliteTicketBackend { author: Some(request.author.unwrap_or_else(default_author)), at: Some(at.clone()), status: None, - from: Some(TicketWorkflowState::Planning.as_str().to_owned()), - to: Some(TicketWorkflowState::Ready.as_str().to_owned()), + from: Some(TicketState::Open.as_str().to_owned()), + to: Some(TicketState::Open.as_str().to_owned()), reason: Some(reason), - state_field: Some("state".to_owned()), + state_field: Some("readiness".to_owned()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!( "Implementation targets [{target_summary}] were validated and the Ticket was marked ready." @@ -3627,7 +3545,7 @@ impl TicketBackend for SqliteTicketBackend { }, )?; let updated = conn.execute( - "UPDATE typed_tickets SET workflow_state = 'ready', workflow_state_explicit = 1, updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'planning'", + "UPDATE typed_tickets SET readiness = 'ready', updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'open'", params![self.workspace_id, ticket_id, at], ) .map_err(sqlite_err)?; @@ -3651,6 +3569,7 @@ impl TicketBackend for SqliteTicketBackend { let requested_ticket = self.resolve_ticket_id(conn, id)?; self.require_active_source_assignment(conn, &requested_ticket)?; let previous = self.load_ticket(conn, &requested_ticket)?; + if previous.meta.workflow_state == TicketState::Closed { return Err(TicketError::Conflict("queue requires an open case; reopen explicitly with update_case".into())); } let mut attributes = BTreeMap::from([ ("queue_root_ticket".to_owned(), requested_ticket.clone()), ("queued_by".to_owned(), queued_by.to_owned()), @@ -3666,7 +3585,7 @@ impl TicketBackend for SqliteTicketBackend { let at = now_utc(); attributes.insert("queued_at".to_owned(), at.clone()); conn.execute( - "UPDATE typed_tickets SET workflow_state = 'queued', status = 'open', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", + "UPDATE typed_tickets SET queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, requested_ticket, queued_by, at], ).map_err(sqlite_err)?; self.insert_event(conn, &requested_ticket, &TicketEvent { @@ -3675,9 +3594,9 @@ impl TicketBackend for SqliteTicketBackend { at: Some(at), status: Some("open".to_owned()), from: Some(previous.meta.workflow_state.to_string()), - to: Some("queued".to_owned()), + to: Some(previous.meta.workflow_state.to_string()), reason: Some("queued".to_owned()), - state_field: Some("state".to_owned()), + state_field: Some("routing".to_owned()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), @@ -3690,29 +3609,6 @@ impl TicketBackend for SqliteTicketBackend { }) } - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> { - if resolution.as_str().trim().is_empty() { - return Err(TicketError::Conflict( - "Ticket close requires a non-empty resolution".to_owned(), - )); - } - self.with_write(|conn| { - let ticket_id = self.resolve_ticket_id(conn, id)?; - let previous = self.load_ticket(conn, &ticket_id)?; - let request = TicketStateUpdate { - expected_content_digest: ticket_content_digest(&previous), - expected_state: previous.meta.workflow_state, - state: TicketWorkflowState::Closed, - operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, - reason: "closed".to_owned(), - references: Vec::new(), - author: Some(default_author()), - }; - self.apply_state_update(conn, previous, request, resolution) - .map(|_| ()) - }) - } - fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -3864,6 +3760,7 @@ impl TicketBackend for SqliteTicketBackend { fn ticket_summary_from_meta(meta: TicketMeta) -> TicketSummary { TicketSummary { + tags: Vec::new(), id: meta.id, resource_key: meta.resource_key, slug: meta.slug, @@ -3945,16 +3842,13 @@ fn relation_notice_for_incoming(relation: &TicketRelation) -> Option bool { - matches!( - state, - TicketWorkflowState::Done | TicketWorkflowState::Closed - ) +fn ticket_state_resolved(state: TicketState) -> bool { + matches!(state, TicketState::Closed) } fn transitive_dependency_blockers( requested_ticket: &str, - states: &HashMap, + states: &HashMap, relations: &[TicketRelation], ) -> Result> { type DependencyEdge = (String, String, TicketRelationKind, Option); @@ -3994,7 +3888,7 @@ fn transitive_dependency_blockers( fn collect( ticket: &str, requested_ticket: &str, - states: &HashMap, + states: &HashMap, prerequisites: &BTreeMap>, marks: &mut BTreeMap, blockers: &mut BTreeMap, @@ -4076,7 +3970,7 @@ fn transitive_dependency_blockers( fn relation_view_from_records( meta: &TicketMeta, records: &[TicketRelation], - states: &HashMap, + states: &HashMap, ) -> TicketRelationView { let mut view = TicketRelationView::default(); for relation in records { @@ -4086,7 +3980,7 @@ fn relation_view_from_records( let state = states .get(&relation.target) .copied() - .unwrap_or(TicketWorkflowState::Planning); + .unwrap_or(TicketState::Open); if !ticket_state_resolved(state) { view.blockers.push(TicketRelationBlocker { blocking_ticket: relation.target.clone(), @@ -4114,7 +4008,7 @@ fn relation_view_from_records( let state = states .get(&relation.ticket_id) .copied() - .unwrap_or(TicketWorkflowState::Planning); + .unwrap_or(TicketState::Open); if !ticket_state_resolved(state) { view.blockers.push(TicketRelationBlocker { blocking_ticket: relation.ticket_id.clone(), @@ -4346,6 +4240,15 @@ fn validate_event_attr(key: &str, value: &str) -> Result<()> { Ok(()) } +fn validate_case_text(label: &str, value: &str) -> Result<()> { + if value.trim().is_empty() || value.contains('\0') { + return Err(TicketError::Conflict(format!( + "Ticket case {label} must be nonblank and NUL-free" + ))); + } + Ok(()) +} + fn validate_required_event_value(label: &str, value: &str) -> Result<()> { if value.trim().is_empty() { return Err(TicketError::Conflict(format!( @@ -4355,26 +4258,6 @@ fn validate_required_event_value(label: &str, value: &str) -> Result<()> { validate_event_attr(label, value) } -fn validate_state_change(change: &TicketStateChange) -> Result<()> { - validate_required_event_value("from", &change.from)?; - validate_required_event_value("to", &change.to)?; - // Reasons are persisted as SQLite text, not legacy thread-comment attributes. - // Preserve free-form Markdown without per-field length or single-line limits. - if change.reason.trim().is_empty() { - return Err(TicketError::Conflict( - "state_changed event requires non-empty reason".to_owned(), - )); - } - if let Some(author) = change.author.as_deref() { - validate_required_event_value("author", author)?; - } - for reference in &change.references { - validate_required_event_value("reference_kind", &reference.kind)?; - validate_required_event_value("reference_target", &reference.target)?; - } - Ok(()) -} - fn validate_intake_summary(summary: &TicketIntakeSummary) -> Result<()> { let body = summary.body.as_str(); if body.trim().is_empty() { @@ -4672,8 +4555,9 @@ mod tests { )); } - fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { + fn summary_with_state(state: TicketState) -> TicketSummary { TicketSummary { + tags: Vec::new(), id: "000TEST".to_string(), resource_key: Some("T-1".to_string()), slug: "000TEST".to_string(), @@ -4691,7 +4575,7 @@ mod tests { } } - fn blocker_with_state(state: TicketWorkflowState) -> TicketRelationBlocker { + fn blocker_with_state(state: TicketState) -> TicketRelationBlocker { TicketRelationBlocker { blocking_ticket: "000BLOCK".to_string(), reason_kind: "depends_on".to_string(), @@ -4715,9 +4599,9 @@ mod tests { #[test] fn transitive_blockers_project_internal_dependency_cycle_as_blocking() { let states = HashMap::from([ - ("root".to_owned(), TicketWorkflowState::Ready), - ("branch".to_owned(), TicketWorkflowState::Ready), - ("cycle".to_owned(), TicketWorkflowState::Ready), + ("root".to_owned(), TicketState::Open), + ("branch".to_owned(), TicketState::Open), + ("cycle".to_owned(), TicketState::Open), ]); let relations = [ dependency_relation("root", "branch"), @@ -4729,7 +4613,7 @@ mod tests { assert!(blockers.iter().any(|blocker| { blocker.blocking_ticket == "root" && blocker.reason_kind == "dependency_cycle" })); - let summary = summary_with_state(TicketWorkflowState::Ready); + let summary = summary_with_state(TicketState::Open); let mut summary = summary; summary.id = "root".to_string(); assert!( @@ -4745,141 +4629,76 @@ mod tests { } #[test] - fn workspace_projection_queues_ready_ticket_for_orchestrator() { - let summary = summary_with_state(TicketWorkflowState::Ready); + fn open_case_projection_does_not_imply_execution_readiness() { + let summary = summary_with_state(TicketState::Open); let projection = project_ticket_workspace_item(&summary, &[], None); assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); assert_eq!( projection.priority, - TicketWorkspaceActionPriority::ReadyForQueue - ); - assert_eq!( - projection.next_action, - Some(TicketWorkspaceNextAction::QueueForOrchestrator) + TicketWorkspaceActionPriority::Background ); + assert_eq!(projection.next_action, None); assert!(projection.queue_guard.can_queue_for_orchestrator); assert!(projection.disabled_reason.is_none()); } #[test] - fn workspace_projection_queues_ready_ticket_with_planning_dependency() { - let summary = summary_with_state(TicketWorkflowState::Ready); - let blockers = [blocker_with_state(TicketWorkflowState::Planning)]; + fn open_case_projection_keeps_dependencies_as_context() { + let summary = summary_with_state(TicketState::Open); + let blockers = [blocker_with_state(TicketState::Open)]; let projection = project_ticket_workspace_item(&summary, &blockers, None); assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); - assert_eq!( - projection.next_action, - Some(TicketWorkspaceNextAction::QueueForOrchestrator) - ); + assert_eq!(projection.next_action, None); assert!(projection.queue_guard.can_queue_for_orchestrator); assert!(projection.blocked_reason.is_some()); assert!(projection.disabled_reason.is_none()); } #[test] - fn workspace_projection_queues_ready_ticket_with_queued_dependency_context() { - let summary = summary_with_state(TicketWorkflowState::Ready); - let blockers = [blocker_with_state(TicketWorkflowState::Queued)]; + fn explicit_queue_guard_remains_separate_from_case_projection() { + let summary = summary_with_state(TicketState::Open); + let blockers = [blocker_with_state(TicketState::Open)]; let projection = project_ticket_workspace_item(&summary, &blockers, None); - assert_eq!( - projection.next_action, - Some(TicketWorkspaceNextAction::QueueForOrchestrator) - ); + assert_eq!(projection.next_action, None); assert!(projection.queue_guard.can_queue_for_orchestrator); assert!(projection.blocked_reason.is_some()); - assert!( - projection - .key_hint - .as_deref() - .unwrap_or_default() - .contains("Queue only") - ); + assert!(projection.key_hint.is_none()); } #[test] - fn workspace_projection_overlay_retains_context_without_queue_gate() { - let summary = summary_with_state(TicketWorkflowState::Ready); + fn workspace_projection_case_overlay_never_implies_execution_progress() { + let summary = summary_with_state(TicketState::Open); let overlay = TicketWorkspaceStateOverlay { - source: "orchestration".to_string(), - workflow_state: TicketWorkflowState::InProgress, + source: "other-branch".into(), + workflow_state: TicketState::Closed, }; let projection = project_ticket_workspace_item(&summary, &[], Some(&overlay)); - - assert_eq!(projection.kind, TicketWorkspaceRowKind::ActiveWork); - assert_eq!( - projection.next_action, - Some(TicketWorkspaceNextAction::WaitForOrchestrator) - ); - assert_eq!(projection.visible_state, "ready→prog"); - assert!(projection.queue_guard.can_queue_for_orchestrator); + assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); + assert_eq!(projection.next_action, None); assert!(projection.visible_overlay.is_some()); + assert!(projection.queue_guard.can_queue_for_orchestrator); + let closed = + project_ticket_workspace_item(&summary_with_state(TicketState::Closed), &[], None); + assert!(!closed.queue_guard.can_queue_for_orchestrator); + assert!(closed.next_action.is_none()); } #[test] - fn generic_state_change_allows_direct_start_without_phase_path() { - assert!( - validate_generic_state_change( - TicketWorkflowState::Ready, - TicketWorkflowState::InProgress - ) - .is_ok() - ); - assert!( - validate_generic_state_change( - TicketWorkflowState::Planning, - TicketWorkflowState::InProgress - ) - .is_ok() - ); - } - - #[test] - fn workflow_state_rejects_legacy_intake_alias() { - assert_eq!( - TicketWorkflowState::parse("planning"), - Some(TicketWorkflowState::Planning) - ); - assert_eq!(TicketWorkflowState::parse("intake"), None); - assert_eq!(TicketWorkflowState::Planning.as_str(), "planning"); - assert_eq!( - TicketWorkflowState::default_for_status(&ExtensibleTicketStatus::Open), - TicketWorkflowState::Planning - ); - } - - #[test] - fn workflow_state_transition_graph_allows_planning_lane_and_returns() { - assert!(TicketWorkflowState::is_planning_ready_transition( - TicketWorkflowState::Planning, - TicketWorkflowState::Ready - )); - assert!(TicketWorkflowState::is_queue_transition( - TicketWorkflowState::Ready, - TicketWorkflowState::Queued - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::InProgress, - TicketWorkflowState::Done - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::Ready, - TicketWorkflowState::Planning - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::Queued, - TicketWorkflowState::Planning - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::Planning, - TicketWorkflowState::Queued - )); + fn case_state_parser_rejects_legacy_workflow_values() { + assert_eq!(TicketState::parse("open"), Some(TicketState::Open)); + for legacy in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "intake", + ] { + assert_eq!(TicketState::parse(legacy), None); + } } #[test] @@ -4888,10 +4707,10 @@ mod tests { let backend = backend(&tmp); let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap(); let mut ready_input = NewTicket::new("Ready Ticket"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); + ready_input.workflow_state = Some(TicketState::Open); let ready = backend.create(ready_input).unwrap(); let mut closed_input = NewTicket::new("Closed Ticket"); - closed_input.workflow_state = Some(TicketWorkflowState::Closed); + closed_input.workflow_state = Some(TicketState::Closed); let closed = backend.create(closed_input).unwrap(); let active = backend.list(TicketListQuery::default()).unwrap(); @@ -4913,14 +4732,13 @@ mod tests { assert!(all_ids.contains(&closed.id.as_str())); let ready_only = backend - .list(TicketListQuery::state(TicketListState::Ready)) + .list(TicketListQuery::state(TicketListState::Open)) .unwrap(); - assert_eq!(ready_only.len(), 1); - assert_eq!(ready_only[0].id, ready.id); + assert_eq!(ready_only.len(), 2); let planning_or_closed = backend .list(TicketListQuery::states([ - TicketListState::Planning, + TicketListState::Open, TicketListState::Closed, ])) .unwrap(); @@ -4930,7 +4748,7 @@ mod tests { .collect::>(); assert!(explicit_ids.contains(&planning.id.as_str())); assert!(explicit_ids.contains(&closed.id.as_str())); - assert!(!explicit_ids.contains(&ready.id.as_str())); + assert!(explicit_ids.contains(&ready.id.as_str())); } #[test] @@ -4940,11 +4758,11 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut root_input = NewTicket::new("Ready root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); + root_input.workflow_state = Some(TicketState::Open); root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); let mut middle_input = NewTicket::new("Queued middle"); - middle_input.workflow_state = Some(TicketWorkflowState::Queued); + middle_input.workflow_state = Some(TicketState::Open); middle_input.targets = vec![write_target("main")]; let middle = backend.create(middle_input).unwrap(); let leaf = backend.create(NewTicket::new("Planning leaf")).unwrap(); @@ -4971,13 +4789,12 @@ mod tests { assert!(check.queue_guard.can_queue_for_orchestrator); assert_eq!(check.queue_tickets, vec![check.ticket.id.clone()]); assert!(check.blockers.iter().any(|blocker| { - blocker.blocking_ticket == leaf.id - && blocker.blocking_state == TicketWorkflowState::Planning + blocker.blocking_ticket == leaf.id && blocker.blocking_state == TicketState::Open })); let page = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 10, after: None, }) @@ -4988,8 +4805,7 @@ mod tests { .find(|item| item.summary.id == check.ticket.id) .unwrap(); assert!(item.relation_blockers.iter().any(|blocker| { - blocker.blocking_ticket == leaf.id - && blocker.blocking_state == TicketWorkflowState::Planning + blocker.blocking_ticket == leaf.id && blocker.blocking_state == TicketState::Open })); assert!( project_ticket_workspace_item(&item.summary, &item.relation_blockers, None) @@ -5023,11 +4839,11 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut root_input = NewTicket::new("Ready root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); + root_input.workflow_state = Some(TicketState::Open); root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); let mut done_input = NewTicket::new("Done dependency"); - done_input.workflow_state = Some(TicketWorkflowState::Done); + done_input.workflow_state = Some(TicketState::Closed); done_input.targets = vec![write_target("main")]; let done = backend.create(done_input).unwrap(); for (ticket_id, target) in [ @@ -5057,7 +4873,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); assert_eq!( backend @@ -5065,7 +4881,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Done + TicketState::Closed ); } @@ -5076,15 +4892,15 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut first_input = NewTicket::new("First ready Ticket"); - first_input.workflow_state = Some(TicketWorkflowState::Ready); + first_input.workflow_state = Some(TicketState::Open); first_input.targets = vec![write_target("main")]; let first = backend.create(first_input).unwrap(); let mut second_input = NewTicket::new("Second ready Ticket"); - second_input.workflow_state = Some(TicketWorkflowState::Ready); + second_input.workflow_state = Some(TicketState::Open); second_input.targets = vec![write_target("main")]; let second = backend.create(second_input).unwrap(); let mut third_input = NewTicket::new("Third ready Ticket"); - third_input.workflow_state = Some(TicketWorkflowState::Ready); + third_input.workflow_state = Some(TicketState::Open); third_input.targets = vec![write_target("main")]; let third = backend.create(third_input).unwrap(); for (ticket, target) in [ @@ -5119,7 +4935,7 @@ mod tests { ); let page = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 10, after: None, }) @@ -5140,7 +4956,7 @@ mod tests { assert_eq!(outcome.queued_tickets, vec![first.id.clone()]); assert_eq!( backend.show(first.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); for ticket_id in [second.id, third.id] { assert_eq!( @@ -5149,7 +4965,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); } } @@ -5161,11 +4977,11 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut dependency_input = NewTicket::new("Invalid ready dependency"); - dependency_input.workflow_state = Some(TicketWorkflowState::Ready); + dependency_input.workflow_state = Some(TicketState::Open); dependency_input.targets = vec![write_target("unknown")]; let dependency = backend.create(dependency_input).unwrap(); let mut root_input = NewTicket::new("Queue root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); + root_input.workflow_state = Some(TicketState::Open); root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); backend @@ -5190,7 +5006,7 @@ mod tests { assert_eq!(outcome.queued_tickets, vec![root.id.clone()]); assert_eq!( backend.show(root.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); for ticket_id in [dependency.id] { assert_eq!( @@ -5199,7 +5015,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); } } @@ -5212,7 +5028,7 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut dependency = NewTicket::new("Dependency"); dependency.targets = vec![write_target("main")]; - dependency.workflow_state = Some(TicketWorkflowState::Ready); + dependency.workflow_state = Some(TicketState::Open); let dependency = backend.create(dependency).unwrap(); let mut implementation = NewTicket::new("Implementation"); implementation.targets = vec![write_target("main")]; @@ -5273,11 +5089,8 @@ mod tests { let queued_dependency = backend .show(TicketIdOrSlug::Id(dependency.id.clone())) .unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!( - queued_dependency.meta.workflow_state, - TicketWorkflowState::Ready - ); + assert_eq!(queued.meta.workflow_state, TicketState::Open); + assert_eq!(queued_dependency.meta.workflow_state, TicketState::Open); assert_eq!(queued.meta.queued_by.as_deref(), Some("orchestrator")); assert_eq!(queued.relations.blockers.len(), 1); assert_eq!(queued.relations.blockers[0].blocking_ticket, dependency.id); @@ -5404,13 +5217,13 @@ mod tests { let db_path = tmp.path().join("workspace.db"); let backend = SqliteTicketBackend::open(&db_path, "workspace-test").unwrap(); let mut blocker_input = NewTicket::new("Blocker"); - blocker_input.workflow_state = Some(TicketWorkflowState::InProgress); + blocker_input.workflow_state = Some(TicketState::Open); let blocker = backend.create(blocker_input).unwrap(); let mut blocked_input = NewTicket::new("Blocked"); - blocked_input.workflow_state = Some(TicketWorkflowState::Ready); + blocked_input.workflow_state = Some(TicketState::Open); let blocked = backend.create(blocked_input).unwrap(); let mut newest_input = NewTicket::new("Newest"); - newest_input.workflow_state = Some(TicketWorkflowState::Ready); + newest_input.workflow_state = Some(TicketState::Open); let newest = backend.create(newest_input).unwrap(); backend .add_ticket_relation( @@ -5488,7 +5301,7 @@ mod tests { ); assert_eq!( blocked_item.relation_blockers[0].blocking_state, - TicketWorkflowState::InProgress + TicketState::Open ); assert_eq!(blocked_item.relation_blockers[0].reason_kind, "depends_on"); let debug = format!("{projection:?}"); @@ -5503,13 +5316,9 @@ mod tests { let backend = SqliteTicketBackend::open(&db_path, "workspace-test").unwrap(); let mut ids = Vec::new(); for (title, state, updated_at) in [ - ("Newest", TicketWorkflowState::Ready, "2026-08-12T03:00:00Z"), - ("Middle", TicketWorkflowState::Ready, "2026-08-12T02:00:00Z"), - ( - "Oldest", - TicketWorkflowState::Planning, - "2026-08-12T01:00:00Z", - ), + ("Newest", TicketState::Open, "2026-08-12T03:00:00Z"), + ("Middle", TicketState::Open, "2026-08-12T02:00:00Z"), + ("Oldest closed", TicketState::Closed, "2026-08-12T01:00:00Z"), ] { let mut input = NewTicket::new(title); input.workflow_state = Some(state); @@ -5533,20 +5342,20 @@ mod tests { .unwrap(); let combined_lane = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready, TicketWorkflowState::Planning], + states: vec![TicketState::Open, TicketState::Closed], limit: 1, after: None, }) .unwrap(); assert_eq!( combined_lane.items[0].summary.workflow_state, - TicketWorkflowState::Ready, + TicketState::Open, "lane pagination order must match the UI's state-primary order", ); let first = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 1, after: None, }) @@ -5555,7 +5364,7 @@ mod tests { assert!(first.has_more); let mut inserted = NewTicket::new("Inserted"); - inserted.workflow_state = Some(TicketWorkflowState::Ready); + inserted.workflow_state = Some(TicketState::Open); let inserted = backend.create(inserted).unwrap(); Connection::open(&db_path) .unwrap() @@ -5567,7 +5376,7 @@ mod tests { let second = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 1, after: first.next, }) @@ -5613,10 +5422,22 @@ mod tests { NewTicketEvent::new(TicketEventKind::Comment, "Imported into SQLite."), ) .unwrap(); + let observed = backend.show(created.id.clone().into()).unwrap(); backend - .close( - TicketIdOrSlug::Id(created.id.clone()), - MarkdownText::new("Done."), + .update_case( + &created.id, + TicketCaseUpdate { + expected_content_digest: ticket_content_digest(&observed), + expected_state: TicketState::Open, + expected_closure_operation_key: None, + operation_key: "close-core".into(), + action: TicketCaseAction::Close { + reason: TicketClosureReason::Completed, + }, + explanation: "Done.".into(), + references: Vec::new(), + author: None, + }, ) .unwrap(); @@ -5626,7 +5447,7 @@ mod tests { let list = reopened.list(TicketListQuery::all()).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].id, created.id); - assert_eq!(list[0].workflow_state, TicketWorkflowState::Closed); + assert_eq!(list[0].workflow_state, TicketState::Closed); let ticket = reopened.show(TicketIdOrSlug::Id(created.id)).unwrap(); assert_eq!(ticket.meta.title, "SQLite Ticket"); assert!(ticket.events.iter().any(|event| { @@ -5659,38 +5480,6 @@ mod tests { ); } - fn assignment_source(role: &str) -> BTreeMap { - BTreeMap::from([ - ("source_runtime_id".to_owned(), "runtime-a".to_owned()), - ("source_worker_id".to_owned(), "worker-a".to_owned()), - ("source_actor_role".to_owned(), role.to_owned()), - ("source_assignment_id".to_owned(), "assignment-a".to_owned()), - ]) - } - - fn active_assignment_fixture(backend: &SqliteTicketBackend, ticket_id: &str) { - let conn = backend.open_connection().unwrap(); - conn.execute_batch( - "CREATE TABLE test_worker_assignments ( - workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, assignment_id TEXT NOT NULL, status TEXT NOT NULL - ); - CREATE VIEW ticket_active_worker_assignments AS - SELECT workspace_id, ticket_id, runtime_id, worker_id, assignment_id - FROM test_worker_assignments WHERE status = 'active';", - ) - .unwrap(); - conn.execute("INSERT INTO test_worker_assignments VALUES (?1, ?2, 'runtime-a', 'worker-a', 'assignment-a', 'active')", params![backend.workspace_id(), ticket_id]).unwrap(); - } - - fn release_assignment_fixture(backend: &SqliteTicketBackend) { - backend - .open_connection() - .unwrap() - .execute("UPDATE test_worker_assignments SET status = 'released'", []) - .unwrap(); - } - #[test] fn content_digest_matches_sqlite_and_ignores_event_order() { let tmp = TempDir::new().unwrap(); @@ -5761,1116 +5550,6 @@ mod tests { assert_eq!(ticket_content_digest(&reordered), digest); } - #[test] - fn unassigned_worker_actor_is_distinct_from_ticket_worker_requiring_assignment() { - for role in ["worker", "ticket_worker"] { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Actor boundary")) - .unwrap(); - let source = backend.clone().with_event_attributes(BTreeMap::from([ - ("source_actor_role".into(), role.into()), - ("source_runtime_id".into(), "runtime".into()), - ("source_worker_id".into(), "worker".into()), - ])); - let result = source.complete(&reference.id, completion_request(&ticket, "complete")); - if role == "worker" { - let completed = result.unwrap(); - assert_eq!( - completed - .events - .last() - .unwrap() - .attributes - .get("source_actor_role") - .map(String::as_str), - Some("worker") - ); - } else { - assert!(result.is_err()); - let reread = backend.show(reference.id.into()).unwrap(); - assert_eq!(reread.meta.workflow_state, ticket.meta.workflow_state); - assert_eq!(reread.events.len(), ticket.events.len()); - } - } - } - - #[test] - fn assignment_fence_accepts_exact_live_source_inside_state_and_close_transaction() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Active source")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - let source = backend.clone().with_event_attributes(assignment_source("ticket_worker")) - .with_mutation_hook(Arc::new(|conn, _| { - assert!(!conn.is_autocommit()); - let count: i64 = conn.query_row("SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE runtime_id = 'runtime-a' AND worker_id = 'worker-a' AND assignment_id = 'assignment-a'", [], |row| row.get(0)).map_err(sqlite_err)?; - assert_eq!(count, 1); - Ok(()) - })); - let completed = source - .complete(&reference.id, completion_request(&ticket, "complete")) - .unwrap(); - source - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("done", "planning", "reopen", "reopen"), - ) - .unwrap(); - source - .close( - reference.id.clone().into(), - MarkdownText::new("Closed by active coder"), - ) - .unwrap(); - let closed = source.show(reference.id.clone().into()).unwrap(); - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - assert_eq!( - completed - .events - .last() - .unwrap() - .attributes - .get("source_assignment_id") - .map(String::as_str), - Some("assignment-a") - ); - } - - #[test] - fn assignment_fence_rejects_every_mismatched_identity_without_mutation() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Exact identity")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - for key in [ - "source_runtime_id", - "source_worker_id", - "source_assignment_id", - ] { - let mut attrs = assignment_source("ticket_worker"); - attrs.insert(key.to_owned(), "other".to_owned()); - let source = backend.clone().with_event_attributes(attrs); - assert!( - source - .complete(&reference.id, completion_request(&ticket, "complete")) - .is_err() - ); - assert!( - source - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", "finished", "finished") - ) - .is_err() - ); - assert!( - source - .close(reference.id.clone().into(), MarkdownText::new("finished")) - .is_err() - ); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - } - let (other, other_ticket) = backend - .create_with_snapshot(NewTicket::new("Different Ticket")) - .unwrap(); - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - assert!( - source - .complete(&other.id, completion_request(&other_ticket, "complete")) - .is_err() - ); - assert_eq!(backend.show(other.id.into()).unwrap(), other_ticket); - let other_workspace = - SqliteTicketBackend::open(backend.db_path(), "other-workspace").unwrap(); - let (other, other_ticket) = other_workspace - .create_with_snapshot(NewTicket::new("Different Workspace")) - .unwrap(); - let source = other_workspace - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - assert!( - source - .complete(&other.id, completion_request(&other_ticket, "complete")) - .is_err() - ); - assert_eq!(other_workspace.show(other.id.into()).unwrap(), other_ticket); - } - - #[test] - fn assignment_fence_rejects_missing_identity_or_active_view_and_non_coder_assignment_sources() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Missing identity")) - .unwrap(); - for key in [ - "source_runtime_id", - "source_worker_id", - "source_assignment_id", - ] { - let mut attrs = assignment_source("ticket_worker"); - attrs.remove(key); - let source = backend.clone().with_event_attributes(attrs); - assert!( - source - .complete(&reference.id, completion_request(&ticket, "complete")) - .is_err() - ); - } - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - assert!( - source - .complete(&reference.id, completion_request(&ticket, "complete")) - .is_err() - ); - active_assignment_fixture(&backend, &reference.id); - release_assignment_fixture(&backend); - let source = backend - .clone() - .with_event_attributes(assignment_source("orchestrator")); - assert!( - source - .complete(&reference.id, completion_request(&ticket, "complete")) - .is_err() - ); - assert_eq!(backend.show(reference.id.into()).unwrap(), ticket); - } - - #[test] - fn released_assignment_cannot_mutate_reopened_ticket_but_can_exactly_replay() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Released source")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - let request = completion_request(&ticket, "complete"); - let completed = source.complete(&reference.id, request.clone()).unwrap(); - release_assignment_fixture(&backend); - let mut orchestrator_attrs = assignment_source("orchestrator"); - orchestrator_attrs.remove("source_assignment_id"); - let orchestrator = backend.clone().with_event_attributes(orchestrator_attrs); - let reopened = orchestrator - .update_state( - &reference.id, - TicketStateUpdate { - state: TicketWorkflowState::Planning, - author: Some("registered-orchestrator".to_owned()), - ..completion_request(&completed, "reopen").into() - }, - ) - .unwrap(); - assert_eq!( - source.complete(&reference.id, request.clone()).unwrap(), - reopened - ); - assert_eq!( - &reopened.events[..completed.events.len()], - completed.events.as_slice() - ); - assert!( - source - .complete(&reference.id, completion_request(&reopened, "new-complete")) - .is_err() - ); - assert!( - source - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", "finished", "finished") - ) - .is_err() - ); - assert!( - source - .close(reference.id.clone().into(), MarkdownText::new("finished")) - .is_err() - ); - let close = TicketStateUpdate { - state: TicketWorkflowState::Closed, - ..completion_request(&reopened, "close").into() - }; - assert!(source.update_state(&reference.id, close).is_err()); - assert_eq!(backend.show(reference.id.into()).unwrap(), reopened); - let conn = backend.open_connection().unwrap(); - let status: String = conn - .query_row("SELECT status FROM test_worker_assignments", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(status, "released"); - } - - #[test] - fn assignment_source_actor_is_bound_to_replay_fingerprint() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Actor fingerprint")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - let request = completion_request(&ticket, "complete"); - let completed = source.complete(&reference.id, request.clone()).unwrap(); - release_assignment_fixture(&backend); - for key in [ - "source_runtime_id", - "source_worker_id", - "source_assignment_id", - "source_actor_role", - ] { - let mut attrs = assignment_source("ticket_worker"); - attrs.insert(key.to_owned(), "different-source".to_owned()); - let other_source = backend.clone().with_event_attributes(attrs); - assert!(matches!( - other_source.complete(&reference.id, request.clone()), - Err(TicketError::OperationFingerprintMismatch { .. }) - )); - } - assert_eq!(backend.show(reference.id.into()).unwrap(), completed); - } - - #[test] - fn legacy_state_and_close_share_transactional_audit_and_operation_receipts() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Legacy boundary")) - .unwrap(); - let body = MarkdownText::new("## Custom implementation report\n\nPreserved body.\n"); - let references = vec![TicketReference { - kind: "report".to_owned(), - target: "test-results".to_owned(), - }]; - let mut change = TicketStateChange::new("planning", "done", "finished", body.clone()); - change.references = references.clone(); - change.author = Some("legacy-author".to_owned()); - backend - .set_workflow_state(reference.id.clone().into(), change) - .unwrap(); - let done = backend.show(reference.id.clone().into()).unwrap(); - let event = done.events.last().unwrap(); - assert_eq!(event.body, body); - assert_eq!(event.references, references); - assert_eq!(event.author.as_deref(), Some("legacy-author")); - assert_eq!(event.from.as_deref(), Some("planning")); - assert_eq!(event.reason.as_deref(), Some("finished")); - assert_eq!( - event.attributes.get("expected_content_digest"), - Some(&ticket_content_digest(&ticket)) - ); - assert_eq!( - event - .attributes - .get("fingerprint_version") - .map(String::as_str), - Some("state-update-v2") - ); - let request = TicketStateUpdate { - expected_content_digest: ticket_content_digest(&ticket), - expected_state: TicketWorkflowState::Planning, - state: TicketWorkflowState::Done, - operation_key: event.attributes["operation_key"].clone(), - reason: "finished".to_owned(), - references, - author: Some("legacy-author".to_owned()), - }; - let replay = backend - .with_write(|conn| { - let snapshot = backend.load_ticket(conn, &reference.id)?; - backend.apply_state_update(conn, snapshot, request.clone(), body.clone()) - }) - .unwrap(); - assert_eq!(replay, done); - let resolution = - MarkdownText::new(format!("## Resolution\n\n{}\n", "details ".repeat(200))); - backend - .close(reference.id.clone().into(), resolution.clone()) - .unwrap(); - let closed = backend.show(reference.id.clone().into()).unwrap(); - let close = closed.events.last().unwrap(); - assert_eq!(close.kind, TicketEventKind::Close); - assert_eq!(close.from.as_deref(), Some("done")); - assert_eq!(close.to.as_deref(), Some("closed")); - assert_eq!(close.reason.as_deref(), Some("closed")); - assert_eq!(close.body, resolution); - assert_eq!(closed.resolution, Some(resolution)); - assert_eq!( - close.attributes.get("expected_content_digest"), - Some(&ticket_content_digest(&done)) - ); - assert_ne!( - close.attributes["operation_key"], - event.attributes["operation_key"] - ); - let conn = backend.open_connection().unwrap(); - let receipt_attributes = backend - .load_event_attributes( - &conn, - &reference.id, - close.attributes["event_sequence"].parse().unwrap(), - ) - .unwrap(); - assert_eq!(receipt_attributes.len(), 4); - let replay = backend - .with_write(|conn| { - let current = backend.load_ticket(conn, &reference.id)?; - backend.apply_state_update(conn, current, request, body) - }) - .unwrap(); - assert_eq!(replay, closed); - assert_eq!(&replay.events[..done.events.len()], done.events.as_slice()); - } - - #[test] - fn legacy_state_and_close_hooks_observe_new_state_and_rollback_common_boundary() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Legacy rollback")) - .unwrap(); - let failing = backend.clone().with_mutation_hook(Arc::new(|conn, event| { - assert!(!conn.is_autocommit()); - let (current, audited): (String, String) = conn.query_row("SELECT t.workflow_state, e.to_state FROM typed_tickets t JOIN typed_ticket_events e ON e.workspace_id = t.workspace_id AND e.ticket_id = t.ticket_id WHERE t.workspace_id = ?1 AND t.ticket_id = ?2 AND e.event_index = ?3", params![event.workspace_id, event.ticket_id, event.event_index], |row| Ok((row.get(0)?, row.get(1)?))).map_err(sqlite_err)?; - assert_eq!(current, audited); - Err(TicketError::Conflict("reject mutation".to_owned())) - })); - assert!( - failing - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", "finished", "custom body") - ) - .is_err() - ); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - assert!( - failing - .close(reference.id.clone().into(), MarkdownText::new("Resolution")) - .is_err() - ); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - let count: i64 = backend - .open_connection() - .unwrap() - .query_row( - "SELECT COUNT(*) FROM typed_ticket_event_attributes WHERE key = 'operation_key'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(count, 0); - } - - #[test] - fn authenticated_receipt_context_replays_after_live_role_and_assignment_clearance() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Role clearance replay")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - let request = completion_request(&ticket, "complete"); - let completed = source.complete(&reference.id, request.clone()).unwrap(); - release_assignment_fixture(&backend); - let mut current_context = assignment_source("reader"); - current_context.remove("source_assignment_id"); - // Parent authenticates unchanged runtime/worker/author and operation before - // restoring only receipt role/assignment metadata from the recorded audit. - let receipt = completed.events.last().unwrap(); - for key in ["source_actor_role", "source_assignment_id"] { - current_context.insert(key.to_owned(), receipt.attributes[key].clone()); - } - let replay_source = backend.clone().with_event_attributes(current_context); - assert_eq!( - replay_source.complete(&reference.id, request).unwrap(), - completed - ); - assert!( - replay_source - .complete( - &reference.id, - completion_request(&completed, "new-complete") - ) - .is_err() - ); - assert_eq!(backend.show(reference.id.into()).unwrap(), completed); - } - - #[test] - fn common_state_mutation_preserves_actual_historical_ticket_completion_events() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let reference = backend - .create(NewTicket::new("Historical MR completion")) - .unwrap(); - let historical = backend.clone().with_event_attributes(BTreeMap::from([ - ( - "operation_key".to_owned(), - "historical-mr-completion".to_owned(), - ), - ("merge_request_id".to_owned(), "MR-existing".to_owned()), - ])); - let mut event = NewTicketEvent::new( - TicketEventKind::Other("ticket_completion".to_owned()), - "Historical Merge Request completion", - ); - event.references.push(TicketReference { - kind: "merge_request".to_owned(), - target: "MR-existing".to_owned(), - }); - historical - .add_event(reference.id.clone().into(), event) - .unwrap(); - let previous = backend.show(reference.id.clone().into()).unwrap(); - backend - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", "completed", "completed"), - ) - .unwrap(); - backend - .close(reference.id.clone().into(), MarkdownText::new("Resolved")) - .unwrap(); - let current = backend.show(reference.id.into()).unwrap(); - assert_eq!( - ¤t.events[..previous.events.len()], - previous.events.as_slice() - ); - assert_eq!( - current.events[1].kind, - TicketEventKind::Other("ticket_completion".to_owned()) - ); - } - - fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion { - TicketCompletion { - expected_content_digest: ticket_content_digest(ticket), - expected_state: ticket.meta.workflow_state, - operation_key: key.to_owned(), - reason: "Acceptance criteria met".to_owned(), - references: Vec::new(), - author: Some("tester".to_owned()), - } - } - - #[test] - fn frozen_counter_receipts_remain_immutable_and_cannot_be_reinterpreted_as_content_requests() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Old receipt")) - .unwrap(); - let request = completion_request(&ticket, "old-operation"); - backend.complete(&reference.id, request.clone()).unwrap(); - let connection = backend.open_connection().unwrap(); - // Frozen pre-content fixture. Its old fingerprint cannot prove a new - // content-based request, and migration must not rewrite immutable audit. - connection.execute( - "UPDATE typed_ticket_event_attributes SET value='state-update-v1' WHERE ticket_id=?1 AND key='fingerprint_version'", - [&reference.id], - ).unwrap(); - connection.execute( - "UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'", - [&reference.id], - ).unwrap(); - let before = backend.show(reference.id.clone().into()).unwrap(); - migrate_sqlite_ticket_schema(&connection).unwrap(); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); - assert!(matches!( - backend.complete(&reference.id, request), - Err(TicketError::OperationFingerprintMismatch { .. }), - )); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); - let fresh = backend - .complete( - &reference.id, - completion_request(&before, "fresh-operation"), - ) - .unwrap(); - assert_eq!(fresh.events.len(), before.events.len() + 1); - assert_eq!( - fresh - .events - .last() - .unwrap() - .attributes - .get("expected_content_digest"), - Some(&ticket_content_digest(&before)) - ); - } - - #[test] - fn completion_from_all_states_needs_no_git_targets_or_merge_request() { - let tmp = TempDir::new().unwrap(); - let backend = - SqliteTicketBackend::open(tmp.path().join("tickets.db"), "workspace-test").unwrap(); - for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - TicketWorkflowState::Done, - TicketWorkflowState::Closed, - ] { - let mut input = NewTicket::new(format!("Complete {state}")); - input.workflow_state = Some(state); - let (reference, ticket) = backend.create_with_snapshot(input).unwrap(); - let completed = backend - .complete(&reference.id, completion_request(&ticket, "complete")) - .unwrap(); - assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done); - assert_eq!(completed.meta.status, ExtensibleTicketStatus::Open); - assert_eq!(completed.meta.targets, ticket.meta.targets); - assert_eq!(completed.events.len(), ticket.events.len() + 1); - let event = completed.events.last().unwrap(); - assert_eq!(event.author.as_deref(), Some("tester")); - assert_eq!(event.from.as_deref(), Some(state.as_str())); - assert_eq!(event.to.as_deref(), Some("done")); - assert!(event.references.is_empty()); - assert_eq!(backend.show(reference.id.into()).unwrap(), completed); - } - } - - #[test] - fn completion_preserves_long_and_multiline_reasons_across_reload_and_replay() { - let tmp = TempDir::new().unwrap(); - let db = tmp.path().join("tickets.db"); - let backend = SqliteTicketBackend::open(&db, "workspace-test").unwrap(); - for reason in [ - "x".repeat(1025), - "日本語の完了理由を省略せず記録する。".repeat(1024), - "## 完了理由\r\n\n- 検証済み\n\n".repeat(1024), - ] { - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Long completion reason")) - .unwrap(); - let request = TicketCompletion { - reason: reason.clone(), - ..completion_request(&ticket, "complete") - }; - let completed = backend.complete(&reference.id, request.clone()).unwrap(); - assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done); - assert_eq!(completed.events.len(), ticket.events.len() + 1); - let event = completed.events.last().unwrap(); - assert_eq!(event.reason.as_deref(), Some(reason.as_str())); - assert_eq!(event.body.as_str(), reason); - - let reopened = SqliteTicketBackend::open(&db, "workspace-test").unwrap(); - assert_eq!( - reopened.show(reference.id.clone().into()).unwrap(), - completed - ); - assert_eq!( - reopened.complete(&reference.id, request.clone()).unwrap(), - completed - ); - let changed = TicketCompletion { - reason: format!("{reason}追加の判断"), - ..request - }; - assert!(matches!( - reopened.complete(&reference.id, changed), - Err(TicketError::OperationFingerprintMismatch { .. }) - )); - } - } - - #[test] - fn state_changes_and_close_preserve_long_markdown_reasons_and_bodies() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, _) = backend - .create_with_snapshot(NewTicket::new("Long state and close reasons")) - .unwrap(); - let reason = "## 判断\n\n長文の理由を記録する。\n".repeat(1024); - let body = "## 検証結果\n\n本文も省略しない。\n".repeat(1024); - backend - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "inprogress", reason.clone(), body.clone()), - ) - .unwrap(); - let current = backend.show(reference.id.clone().into()).unwrap(); - let event = current.events.last().unwrap(); - assert_eq!(event.reason.as_deref(), Some(reason.as_str())); - assert_eq!(event.body.as_str(), body); - let request = TicketStateUpdate { - state: TicketWorkflowState::Closed, - reason: reason.clone(), - ..completion_request(¤t, "close").into() - }; - let closed = backend - .update_state(&reference.id, request.clone()) - .unwrap(); - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - assert_eq!(closed.resolution, Some(MarkdownText::new(&reason))); - assert_eq!( - closed.events.last().unwrap().reason.as_deref(), - Some(reason.as_str()) - ); - assert_eq!( - backend.update_state(&reference.id, request).unwrap(), - closed - ); - } - - #[test] - fn completion_cas_rejects_stale_item_or_state_without_mutation() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend.create_with_snapshot(NewTicket::new("CAS")).unwrap(); - let request = completion_request(&ticket, "complete"); - let edited = backend - .edit_item( - reference.id.clone().into(), - TicketItemEdit { - title: Some("Edited".to_owned()), - ..Default::default() - }, - ) - .unwrap(); - assert!(matches!( - backend.complete(&reference.id, request), - Err(TicketError::StaleContent { .. }) - )); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited); - let mut request = completion_request(&edited, "complete"); - request.expected_state = TicketWorkflowState::Ready; - assert!(matches!( - backend.complete(&reference.id, request), - Err(TicketError::StaleWorkflowState { .. }) - )); - assert_eq!(backend.show(reference.id.into()).unwrap(), edited); - } - - #[test] - fn completion_exact_replay_survives_later_edits_reopen_and_backend_restart() { - let tmp = TempDir::new().unwrap(); - let db = tmp.path().join("tickets.db"); - let backend = SqliteTicketBackend::open(&db, "workspace-test").unwrap(); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Replay")) - .unwrap(); - let mut request = completion_request(&ticket, "complete"); - request.references = vec![TicketReference { - kind: "test_report".to_owned(), - target: "optional-evidence".to_owned(), - }]; - let completed = backend.complete("T-1", request.clone()).unwrap(); - assert_eq!( - completed.events.last().unwrap().references, - request.references - ); - let reopened = backend - .update_state( - &reference.id, - TicketStateUpdate { - state: TicketWorkflowState::Planning, - operation_key: "reopen".to_owned(), - ..completion_request(&completed, "unused").into() - }, - ) - .unwrap(); - let edited = backend - .edit_item( - reference.id.clone().into(), - TicketItemEdit { - body: Some(MarkdownText::new("New body")), - ..Default::default() - }, - ) - .unwrap(); - let backend = SqliteTicketBackend::open(&db, "workspace-test").unwrap(); - let replayed = backend.complete(&reference.id, request.clone()).unwrap(); - assert_eq!(replayed, edited); - assert_eq!(replayed.meta.workflow_state, TicketWorkflowState::Planning); - assert_eq!(replayed.document.body.as_str(), "New body"); - assert_eq!( - &replayed.events[..completed.events.len()], - completed.events.as_slice() - ); - let original_receipt = replayed - .events - .iter() - .find(|event| event.attributes.get("operation_key") == Some(&request.operation_key)) - .unwrap(); - assert_eq!(original_receipt, completed.events.last().unwrap()); - assert_eq!(replayed.events.len(), edited.events.len()); - assert_eq!(backend.show(reference.id.into()).unwrap(), edited); - assert_eq!(reopened.meta.workflow_state, TicketWorkflowState::Planning); - } - - #[test] - fn state_operation_receipts_store_constant_metadata_without_ticket_snapshots() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let mut input = NewTicket::new("Linear receipt storage"); - input.body = MarkdownText::new("Large authoritative item body. ".repeat(2000)); - let (reference, mut current) = backend.create_with_snapshot(input).unwrap(); - let first_request = completion_request(¤t, "complete-0"); - let operations = 16; - for index in 0..operations { - let request = completion_request(¤t, &format!("complete-{index}")); - current = backend.complete(&reference.id, request).unwrap(); - } - let conn = backend.open_connection().unwrap(); - let (count, bytes): (i64, i64) = conn.query_row( - "SELECT COUNT(*), COALESCE(SUM(length(value)), 0) FROM typed_ticket_event_attributes WHERE workspace_id = ?1 AND ticket_id = ?2", - params![backend.workspace_id(), reference.id], |row| Ok((row.get(0)?, row.get(1)?)), - ).unwrap(); - assert_eq!(count, operations * 4); - assert!(bytes < operations * 256); - let original_receipt = current.events[1].clone(); - let replayed = backend.complete(&reference.id, first_request).unwrap(); - assert_eq!(replayed, current); - assert_eq!(replayed.events[1], original_receipt); - assert_eq!(replayed.events.len(), operations as usize + 1); - let count_after: i64 = conn.query_row( - "SELECT COUNT(*) FROM typed_ticket_event_attributes WHERE workspace_id = ?1 AND ticket_id = ?2", - params![backend.workspace_id(), reference.id], |row| row.get(0), - ).unwrap(); - assert_eq!(count_after, count); - } - - #[test] - fn completion_fingerprint_binds_every_request_field_and_operation_kind() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Fingerprint")) - .unwrap(); - let request = completion_request(&ticket, "complete"); - let completed = backend.complete(&reference.id, request.clone()).unwrap(); - let variants = [ - TicketCompletion { - reason: "Changed reason".to_owned(), - ..request.clone() - }, - TicketCompletion { - author: Some("other".to_owned()), - ..request.clone() - }, - TicketCompletion { - author: None, - ..request.clone() - }, - TicketCompletion { - expected_content_digest: "other-content".to_owned(), - ..request.clone() - }, - TicketCompletion { - expected_state: TicketWorkflowState::Ready, - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: "report".to_owned(), - target: "different".to_owned(), - }], - ..request.clone() - }, - ]; - for changed in variants { - assert!(matches!( - backend.complete(&reference.id, changed), - Err(TicketError::OperationFingerprintMismatch { .. }) - )); - } - let close = TicketStateUpdate { - state: TicketWorkflowState::Closed, - ..request.into() - }; - assert!(matches!( - backend.update_state(&reference.id, close), - Err(TicketError::OperationFingerprintMismatch { .. }) - )); - assert_eq!(backend.show(reference.id.into()).unwrap(), completed); - } - - #[test] - fn audited_close_reopen_preserves_resolution_history_and_resource_identity() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Close reopen")) - .unwrap(); - let close = TicketStateUpdate { - state: TicketWorkflowState::Closed, - ..completion_request(&ticket, "close").into() - }; - let closed = backend.update_state(&reference.id, close.clone()).unwrap(); - assert_eq!(closed.meta.status, ExtensibleTicketStatus::Closed); - assert_eq!(closed.resolution, Some(MarkdownText::new(&close.reason))); - assert_eq!(closed.events.last().unwrap().kind, TicketEventKind::Close); - assert_eq!(closed.events.last().unwrap().author, close.author); - let reopen = TicketStateUpdate { - state: TicketWorkflowState::Planning, - ..completion_request(&closed, "reopen").into() - }; - let reopened = backend.update_state(&reference.id, reopen.clone()).unwrap(); - assert_eq!(reopened.meta.status, ExtensibleTicketStatus::Open); - assert_eq!(reopened.meta.resource_key, closed.meta.resource_key); - assert_eq!(reopened.resolution, closed.resolution); - assert_eq!( - &reopened.events[..closed.events.len()], - closed.events.as_slice() - ); - let replayed = backend.update_state(&reference.id, close).unwrap(); - assert_eq!(replayed, reopened); - assert_eq!(replayed.meta.workflow_state, TicketWorkflowState::Planning); - assert_eq!( - &replayed.events[..closed.events.len()], - closed.events.as_slice() - ); - assert_eq!( - backend.update_state(&reference.id, reopen).unwrap(), - reopened - ); - assert_eq!(backend.show(reference.id.into()).unwrap(), reopened); - } - - #[test] - fn completion_mutation_hook_failure_rolls_back_state_event_and_replay() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Rollback")) - .unwrap(); - let request = completion_request(&ticket, "complete"); - let failing = backend.clone().with_mutation_hook(Arc::new(|_, _| { - Err(TicketError::Conflict("reject audit".to_owned())) - })); - assert!(failing.complete(&reference.id, request.clone()).is_err()); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - let completed = backend.complete(&reference.id, request.clone()).unwrap(); - assert_eq!(backend.complete(&reference.id, request).unwrap(), completed); - } - - #[test] - fn completion_concurrent_exact_replay_appends_one_event() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Concurrent")) - .unwrap(); - let request = completion_request(&ticket, "complete"); - let barrier = Arc::new(std::sync::Barrier::new(2)); - let handles = (0..2) - .map(|_| { - let backend = backend.clone(); - let reference = reference.clone(); - let request = request.clone(); - let barrier = barrier.clone(); - std::thread::spawn(move || { - barrier.wait(); - backend.complete(&reference.id, request).unwrap() - }) - }) - .collect::>(); - let results = handles - .into_iter() - .map(|handle| handle.join().unwrap()) - .collect::>(); - assert_eq!(results[0], results[1]); - assert_eq!(results[0].events.len(), ticket.events.len() + 1); - assert_eq!(backend.show(reference.id.into()).unwrap(), results[0]); - } - - #[test] - fn content_digest_tracks_targets_and_restored_content_not_comments_or_state() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Content")) - .unwrap(); - let digest = ticket_content_digest(&ticket); - backend - .add_event( - reference.id.clone().into(), - NewTicketEvent::new(TicketEventKind::Comment, "comment"), - ) - .unwrap(); - let commented = backend.show(reference.id.clone().into()).unwrap(); - assert_eq!(ticket_content_digest(&commented), digest); - let completed = backend - .complete(&reference.id, completion_request(&ticket, "complete")) - .unwrap(); - assert_eq!(ticket_content_digest(&completed), digest); - backend - .edit_item( - reference.id.clone().into(), - TicketItemEdit { - targets: Some(TicketTargetsEdit::Set { - targets: vec![write_target("main")], - }), - ..Default::default() - }, - ) - .unwrap(); - let restored = backend - .edit_item( - reference.id.into(), - TicketItemEdit { - targets: Some(TicketTargetsEdit::Clear), - ..Default::default() - }, - ) - .unwrap(); - assert_eq!(ticket_content_digest(&restored), digest); - } - - #[test] - fn completion_rejects_invalid_requests_without_recording_operation_key() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Validation")) - .unwrap(); - let request = completion_request(&ticket, "complete"); - for invalid in [ - TicketCompletion { - operation_key: " ".to_owned(), - ..request.clone() - }, - TicketCompletion { - expected_content_digest: "".to_owned(), - ..request.clone() - }, - TicketCompletion { - reason: "".to_owned(), - ..request.clone() - }, - TicketCompletion { - reason: " \t ".to_owned(), - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: " \t ".to_owned(), - target: "report".to_owned(), - }], - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: "report".to_owned(), - target: " \t ".to_owned(), - }], - ..request.clone() - }, - TicketCompletion { - author: Some("".to_owned()), - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: "".to_owned(), - target: "report".to_owned(), - }], - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: "report".to_owned(), - target: "".to_owned(), - }], - ..request.clone() - }, - ] { - assert!(matches!( - backend.complete(&reference.id, invalid), - Err(TicketError::Conflict(_)) - )); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - } - assert!(backend.complete(&reference.id, request).is_ok()); - } - - #[test] - fn legacy_state_and_close_reject_blank_reasons_resolution_and_references() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Legacy validation")) - .unwrap(); - assert!(matches!( - backend.set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", " \t ", "finished") - ), - Err(TicketError::Conflict(_)) - )); - let mut change = TicketStateChange::new("planning", "done", "finished", "finished"); - change.references.push(TicketReference { - kind: "report".to_owned(), - target: " \t ".to_owned(), - }); - assert!(matches!( - backend.set_workflow_state(reference.id.clone().into(), change), - Err(TicketError::Conflict(_)) - )); - assert!(matches!( - backend.close(reference.id.clone().into(), MarkdownText::new(" \n\t ")), - Err(TicketError::Conflict(_)) - )); - assert_eq!(backend.show(reference.id.into()).unwrap(), ticket); - } - - #[test] - fn completion_and_state_update_dispatch_round_trip_the_shared_api() { - let tmp = TempDir::new().unwrap(); - let backend = crate::tool::TicketToolBackend::new(backend(&tmp)); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Dispatch")) - .unwrap(); - let operation = TicketBackendOperation::Complete { - ticket: "T-1".to_owned(), - request: completion_request(&ticket, "complete"), - }; - let encoded = serde_json::to_string(&operation).unwrap(); - let operation = serde_json::from_str(&encoded).unwrap(); - let TicketBackendOperationResult::Ticket(completed) = - execute_ticket_backend_operation(&backend, operation).unwrap() - else { - panic!("expected Ticket") - }; - assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done); - let operation = TicketBackendOperation::UpdateState { - ticket: reference.id, - request: TicketStateUpdate { - state: TicketWorkflowState::Closed, - ..completion_request(&completed, "close").into() - }, - }; - let encoded = serde_json::to_string(&operation).unwrap(); - let operation = serde_json::from_str(&encoded).unwrap(); - let TicketBackendOperationResult::Ticket(closed) = - execute_ticket_backend_operation(&backend, operation).unwrap() - else { - panic!("expected Ticket") - }; - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - } - #[test] fn queue_assignment_fence_names_only_the_explicit_ticket() { let tmp = TempDir::new().unwrap(); @@ -6918,99 +5597,6 @@ mod tests { ); } - #[test] - fn state_field_rejects_removed_phase_path_without_mutation() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("No phase")) - .unwrap(); - assert!( - backend - .set_state_field( - reference.id.clone().into(), - "phase", - TicketStateChange::new("planning", "done", "finished", "finished") - ) - .is_err() - ); - assert_eq!(backend.show(reference.id.into()).unwrap(), ticket); - } - - #[test] - fn state_defaults_and_queue_transition_round_trip() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let planning = backend.create(NewTicket::new("Default planning")).unwrap(); - let planning = backend.show(TicketIdOrSlug::Id(planning.id)).unwrap(); - assert_eq!(planning.meta.workflow_state, TicketWorkflowState::Planning); - assert!(planning.meta.workflow_state_explicit); - - let mut closed_input = NewTicket::new("Closed state"); - closed_input.workflow_state = Some(TicketWorkflowState::Closed); - let closed = backend.create(closed_input).unwrap(); - let closed = backend.show(TicketIdOrSlug::Id(closed.id)).unwrap(); - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - assert!(closed.meta.workflow_state_explicit); - - let mut ready_input = NewTicket::new("Ready Workflow"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); - ready_input.targets = vec![ticket_target( - "main", - Some("develop"), - TicketTargetAccess::ReadWrite, - )]; - let ready = backend.create(ready_input).unwrap(); - backend - .queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel") - .unwrap(); - - let queued = backend.show(TicketIdOrSlug::Id(ready.id)).unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); - assert!(queued.meta.workflow_state_explicit); - assert_eq!(queued.meta.queued_by.as_deref(), Some("workspace-panel")); - assert!(queued.meta.queued_at.is_some()); - let event = queued - .events - .iter() - .find(|event| event.kind == TicketEventKind::StateChanged) - .unwrap(); - assert_eq!(event.state_field.as_deref(), Some("state")); - assert_eq!(event.from.as_deref(), Some("ready")); - assert_eq!(event.to.as_deref(), Some("queued")); - assert_eq!(event.reason.as_deref(), Some("queued")); - assert!(!event.attributes.contains_key("targets")); - assert_eq!(queued.meta.targets[0].repository_key, "main"); - assert!(!event.attributes.contains_key("repository_id")); - assert!(!event.attributes.contains_key("ref_selector")); - } - - #[test] - fn queue_accepts_all_states_without_targets() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - TicketWorkflowState::Done, - TicketWorkflowState::Closed, - ] { - let mut input = NewTicket::new(format!("Queue {state}")); - input.workflow_state = Some(state); - let ticket = backend.create(input).unwrap(); - let outcome = backend - .queue_ready(ticket.id.clone().into(), "actor") - .unwrap(); - assert_eq!(outcome.queued_tickets, vec![ticket.id.clone()]); - let current = backend.show(ticket.id.into()).unwrap(); - assert_eq!(current.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!(current.meta.status, ExtensibleTicketStatus::Open); - assert_eq!(current.events.last().unwrap().from, Some(state.to_string())); - } - } - #[test] fn mark_ready_resolves_target_and_is_idempotent() { let tmp = TempDir::new().unwrap(); @@ -7034,7 +5620,7 @@ mod tests { let second = backend .mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request) .unwrap(); - assert_eq!(first.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(first.meta.workflow_state, TicketState::Open); assert_eq!( first.meta.targets, vec![ @@ -7072,8 +5658,9 @@ mod tests { .iter() .filter(|event| { event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("planning") - && event.to.as_deref() == Some("ready") + && event.from.as_deref() == Some("open") + && event.to.as_deref() == Some("open") + && event.state_field.as_deref() == Some("readiness") }) .count(), 1 @@ -7123,7 +5710,7 @@ mod tests { .unwrap(); assert_eq!(ready.meta.targets[0].access, TicketTargetAccess::ReadOnly); let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); - assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(unchanged.meta.workflow_state, TicketState::Open); let mut multiple_writes = NewTicket::new("Multiple write targets"); multiple_writes.targets = vec![write_target("main"), write_target("docs")]; @@ -7139,7 +5726,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(ready.meta.workflow_state, TicketState::Open); assert_eq!( ready .meta @@ -7156,12 +5743,12 @@ mod tests { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - TicketWorkflowState::Done, - TicketWorkflowState::Closed, + TicketState::Open, + TicketState::Open, + TicketState::Open, + TicketState::Open, + TicketState::Closed, + TicketState::Closed, ] { let mut input = NewTicket::new(format!("Targets {state}")); input.workflow_state = Some(state); @@ -7194,39 +5781,12 @@ mod tests { } } - #[test] - fn close_sets_state_closed() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let input = NewTicket::new("Close Workflow"); - let ticket = backend.create(input).unwrap(); - - backend - .close( - TicketIdOrSlug::Id(ticket.id.clone()), - MarkdownText::new("Completed."), - ) - .unwrap(); - let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); - assert_eq!(record.meta.status, ExtensibleTicketStatus::Closed); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Closed); - let close = record.events.last().unwrap(); - assert_eq!(close.kind, TicketEventKind::Close); - assert_eq!(close.from.as_deref(), Some("planning")); - assert_eq!(close.to.as_deref(), Some("closed")); - assert_eq!( - close.attributes.get("expected_content_digest"), - Some(&ticket_content_digest(&record)) - ); - assert!(close.attributes.contains_key("operation_key")); - } - #[test] fn ticket_relations_store_forward_and_derive_inverse_blockers() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let mut ready_input = NewTicket::new("Ready Relation Source"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); + ready_input.workflow_state = Some(TicketState::Open); let source = backend.create(ready_input).unwrap(); let target = backend .create(NewTicket::new("Planning Dependency")) @@ -7276,11 +5836,11 @@ mod tests { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let mut waiter_input = NewTicket::new("Ready After Queued Dependency"); - waiter_input.workflow_state = Some(TicketWorkflowState::Ready); + waiter_input.workflow_state = Some(TicketState::Open); waiter_input.targets = vec![write_target("main")]; let waiter = backend.create(waiter_input).unwrap(); let mut dependency_input = NewTicket::new("Queued Dependency"); - dependency_input.workflow_state = Some(TicketWorkflowState::Queued); + dependency_input.workflow_state = Some(TicketState::Open); let dependency = backend.create(dependency_input).unwrap(); backend .add_ticket_relation( @@ -7298,15 +5858,15 @@ mod tests { .queue_ready(TicketIdOrSlug::Id(waiter.id.clone()), "test") .unwrap(); let queued = backend.show(TicketIdOrSlug::Id(waiter.id.clone())).unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(queued.meta.workflow_state, TicketState::Open); assert_eq!(queued.meta.queued_by.as_deref(), Some("test")); let mut incoming_input = NewTicket::new("Ready After Inprogress Blocker"); - incoming_input.workflow_state = Some(TicketWorkflowState::Ready); + incoming_input.workflow_state = Some(TicketState::Open); incoming_input.targets = vec![write_target("main")]; let incoming = backend.create(incoming_input).unwrap(); let mut blocker_input = NewTicket::new("Inprogress Blocker"); - blocker_input.workflow_state = Some(TicketWorkflowState::InProgress); + blocker_input.workflow_state = Some(TicketState::Open); let blocker = backend.create(blocker_input).unwrap(); backend .add_ticket_relation( @@ -7326,10 +5886,7 @@ mod tests { let queued_incoming = backend .show(TicketIdOrSlug::Id(incoming.id.clone())) .unwrap(); - assert_eq!( - queued_incoming.meta.workflow_state, - TicketWorkflowState::Queued - ); + assert_eq!(queued_incoming.meta.workflow_state, TicketState::Open); } #[test] @@ -7337,7 +5894,7 @@ mod tests { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let mut blocked_input = NewTicket::new("Blocked Ready"); - blocked_input.workflow_state = Some(TicketWorkflowState::Ready); + blocked_input.workflow_state = Some(TicketState::Open); let blocked = backend.create(blocked_input).unwrap(); let dependency = backend.create(NewTicket::new("Dependency")).unwrap(); backend @@ -7358,7 +5915,7 @@ mod tests { let unchanged = backend .show(TicketIdOrSlug::Id(blocked.id.clone())) .unwrap(); - assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(unchanged.meta.workflow_state, TicketState::Open); assert_eq!(unchanged.relations.blockers.len(), 1); assert_eq!( unchanged.relations.blockers[0].blocking_ticket, @@ -7366,7 +5923,7 @@ mod tests { ); let mut incoming_input = NewTicket::new("Incoming Blocked Ready"); - incoming_input.workflow_state = Some(TicketWorkflowState::Ready); + incoming_input.workflow_state = Some(TicketState::Open); let incoming = backend.create(incoming_input).unwrap(); let blocker = backend.create(NewTicket::new("Blocker")).unwrap(); backend @@ -7387,10 +5944,7 @@ mod tests { let unchanged_incoming = backend .show(TicketIdOrSlug::Id(incoming.id.clone())) .unwrap(); - assert_eq!( - unchanged_incoming.meta.workflow_state, - TicketWorkflowState::Queued - ); + assert_eq!(unchanged_incoming.meta.workflow_state, TicketState::Open); assert_eq!(unchanged_incoming.relations.blockers.len(), 1); assert_eq!( unchanged_incoming.relations.blockers[0].blocking_ticket, diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 6b8f3c990..793920cf5 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -1,3 +1,16 @@ +//! Ticket schema authority is the verified persisted shape, not a version marker alone. +//! Startup migrations compose case, tag, and assignment schemas without replacing applied +//! migrations; request paths verify only and never acquire DDL authority. +//! +//! Legacy planning/ready/queued/inprogress become open; done/closed become closed with +//! an explicit migration actor and unknown reason, never inferred success. Immutable +//! snapshots retain the old phase, flags, routing, and resolution for downstream handoff, +//! not a second current case authority. Existing tags, tombstones, memberships, receipts, +//! judgments, and audit snapshots survive composition and are not regenerated on rerun. +//! Assignment migration preserves identities and reconciled evidence; ambiguous or +//! historical relationships remain diagnostic unknown handling, not fresh execution input. +//! Migration never starts Workers or restores execution grants. + use std::collections::{BTreeMap, BTreeSet}; use std::time::Duration; @@ -7,23 +20,9 @@ use crate::{Result, TicketError, sqlite_err}; const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MAX_SCHEMA_DIAGNOSTICS: usize = 32; -const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7; -const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 11; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; -#[derive(Clone, Copy)] -struct Migration { - version: i64, - name: &'static str, - apply: fn(&Connection) -> Result<()>, -} - -const MIGRATIONS: &[Migration] = &[Migration { - version: LATEST_SQLITE_TICKET_SCHEMA_VERSION, - name: BASELINE_MIGRATION_NAME, - apply: create_latest_ticket_schema, -}]; - #[derive(Clone, Copy)] struct ExpectedColumn { name: &'static str, @@ -41,6 +40,9 @@ struct ExpectedForeignKey { } const OWNED_TABLES: &[&str] = &[ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", "typed_tickets", "typed_ticket_targets", "typed_ticket_labels", @@ -52,6 +54,9 @@ const OWNED_TABLES: &[&str] = &[ "typed_ticket_relations", "typed_ticket_orchestration_plans", "typed_ticket_artifacts", + "typed_ticket_case_metadata", + "typed_ticket_case_history", + "typed_ticket_legacy_workflow_snapshot", "workspace_resource_keys", ]; @@ -96,6 +101,57 @@ const TICKET_COLUMNS: &[ExpectedColumn] = &[ column("resolution", "TEXT", false, 0), ]; +const TAG_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("tag_id", "TEXT", true, 2), + column("name", "TEXT", true, 0), + column("normalized_name", "TEXT", true, 0), + column("last_operation_key", "TEXT", true, 0), + column("deleted", "INTEGER", true, 0), +]; +const TICKET_TAG_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("tag_id", "TEXT", true, 3), +]; +const TAG_OPERATION_COLUMNS: &[ExpectedColumn] = &[ + column("workspace_id", "TEXT", true, 1), + column("operation_key", "TEXT", true, 2), + column("kind", "TEXT", true, 0), + column("target_id", "TEXT", true, 0), + column("input_json", "TEXT", true, 0), + column("source_context_json", "TEXT", true, 0), + column("before_json", "TEXT", true, 0), + column("result_json", "TEXT", true, 0), + column("at", "TEXT", true, 0), +]; +const TICKET_TAG_FOREIGN_KEYS: &[ExpectedForeignKey] = &[ + ExpectedForeignKey { + from: "workspace_id", + target_table: "typed_tickets", + to: "workspace_id", + on_delete: "CASCADE", + }, + ExpectedForeignKey { + from: "ticket_id", + target_table: "typed_tickets", + to: "ticket_id", + on_delete: "CASCADE", + }, + ExpectedForeignKey { + from: "workspace_id", + target_table: "workspace_tags", + to: "workspace_id", + on_delete: "RESTRICT", + }, + ExpectedForeignKey { + from: "tag_id", + target_table: "workspace_tags", + to: "tag_id", + on_delete: "RESTRICT", + }, +]; + const TARGET_COLUMNS: &[ExpectedColumn] = &[ column("workspace_id", "TEXT", true, 1), column("ticket_id", "TEXT", true, 2), @@ -254,66 +310,7 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { .execute_batch("BEGIN IMMEDIATE") .map_err(sqlite_err)?; - let result = (|| { - connection - .execute_batch( - "CREATE TABLE IF NOT EXISTS ticket_schema_migrations ( - version INTEGER PRIMARY KEY, - name TEXT NOT NULL, - applied_at TEXT NOT NULL - );", - ) - .map_err(sqlite_err)?; - verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?; - - let applied = load_applied_migrations(connection)?; - if applied.is_empty() { - let migration = MIGRATIONS - .first() - .ok_or_else(|| TicketError::Sqlite("Ticket migration catalog is empty".into()))?; - (migration.apply)(connection)?; - connection - .execute( - "INSERT INTO ticket_schema_migrations (version, name, applied_at) - VALUES (?1, ?2, ?3)", - params![ - migration.version, - migration.name, - chrono::Utc::now().to_rfc3339() - ], - ) - .map_err(sqlite_err)?; - } else if applied - == BTreeMap::from([( - PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION, - BASELINE_MIGRATION_NAME.to_string(), - )]) - { - migrate_v6_to_v7(connection)?; - connection - .execute( - "DELETE FROM ticket_schema_migrations WHERE version = ?1", - params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION], - ) - .map_err(sqlite_err)?; - connection - .execute( - "INSERT INTO ticket_schema_migrations (version, name, applied_at) - VALUES (?1, ?2, ?3)", - params![ - LATEST_SQLITE_TICKET_SCHEMA_VERSION, - BASELINE_MIGRATION_NAME, - chrono::Utc::now().to_rfc3339() - ], - ) - .map_err(sqlite_err)?; - } else { - validate_applied_migrations(&applied)?; - } - - verify_sqlite_ticket_schema(connection) - })(); - + let result = migrate_sqlite_ticket_schema_in_transaction(connection); match result { Ok(()) => connection.execute_batch("COMMIT").map_err(sqlite_err), Err(error) => { @@ -323,8 +320,170 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } } +/// Composes retained 6/7, tags-only 8 or cases-only 9 through component 10, then +/// appends the 10→11 assignment migration without replacing the applied 10 marker. +/// The host owns the surrounding transaction (for example Workspace 91→92). +/// A savepoint rolls back this component on failure, without committing host work. +/// Current component 11 is verified only; immutable judgments are never reapplied. +pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> Result<()> { + if connection.is_autocommit() { + return Err(TicketError::Sqlite( + "Ticket component migration requires a host transaction".into(), + )); + } + connection + .execute_batch("SAVEPOINT ticket_component_composition") + .map_err(sqlite_err)?; + let result = (|| { + connection + .execute_batch( + "CREATE TABLE IF NOT EXISTS ticket_schema_migrations ( + version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL + );", + ) + .map_err(sqlite_err)?; + verify_table(connection, MIGRATION_TABLE, MIGRATION_COLUMNS, &[], false)?; + let applied = load_applied_migrations(connection)?; + if applied.is_empty() { + create_latest_ticket_schema(connection)?; + connection + .execute( + "INSERT INTO ticket_schema_migrations VALUES (11, ?1, ?2)", + params![BASELINE_MIGRATION_NAME, chrono::Utc::now().to_rfc3339()], + ) + .map_err(sqlite_err)?; + } else { + let version = if applied + == BTreeMap::from([ + (10, BASELINE_MIGRATION_NAME.to_owned()), + (11, "Ticket-owned assignment tracking".to_owned()), + ]) { + 11 + } else { + match applied.iter().next() { + Some((&version, name)) + if applied.len() == 1 + && name == BASELINE_MIGRATION_NAME + && matches!(version, 6..=11) => + { + version + } + _ => { + validate_applied_migrations(&applied)?; + unreachable!() + } + } + }; + if version == 6 { + migrate_v6_to_v7(connection)?; + connection + .execute( + "UPDATE ticket_schema_migrations SET version = 7 WHERE version = 6", + [], + ) + .map_err(sqlite_err)?; + } + let shape = TicketSchemaShape::from_version(if version == 6 { 7 } else { version }); + verify_ticket_schema_shape(connection, shape)?; + match shape { + TicketSchemaShape::Legacy7 => { + add_tag_schema(connection)?; + migrate_legacy_cases(connection)?; + } + TicketSchemaShape::Tags8 => migrate_legacy_cases(connection)?, + TicketSchemaShape::Cases9 => add_tag_schema(connection)?, + TicketSchemaShape::Composed10 | TicketSchemaShape::Tracking11 => {} + } + if version < 10 { + connection + .execute( + "UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1", + params![chrono::Utc::now().to_rfc3339()], + ) + .map_err(sqlite_err)?; + } + if version < 11 { + crate::assignment_tracking::migrate_assignment_tracking(connection)?; + connection.execute("INSERT INTO ticket_schema_migrations VALUES (11, 'Ticket-owned assignment tracking', ?1)", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?; + } + } + verify_sqlite_ticket_schema(connection) + })(); + match result { + Ok(()) => connection + .execute_batch("RELEASE ticket_component_composition") + .map_err(sqlite_err), + Err(error) => { + let _ = connection.execute_batch( + "ROLLBACK TO ticket_component_composition; RELEASE ticket_component_composition", + ); + Err(error) + } + } +} + +#[derive(Clone, Copy)] +enum TicketSchemaShape { + Legacy7, + Tags8, + Cases9, + Composed10, + Tracking11, +} +impl TicketSchemaShape { + fn from_version(version: i64) -> Self { + match version { + 7 => Self::Legacy7, + 8 => Self::Tags8, + 9 => Self::Cases9, + 10 => Self::Composed10, + 11 => Self::Tracking11, + _ => unreachable!(), + } + } + fn version(self) -> i64 { + match self { + Self::Legacy7 => 7, + Self::Tags8 => 8, + Self::Cases9 => 9, + Self::Composed10 => 10, + Self::Tracking11 => 11, + } + } + fn tags(self) -> bool { + matches!(self, Self::Tags8 | Self::Composed10 | Self::Tracking11) + } + fn cases(self) -> bool { + matches!(self, Self::Cases9 | Self::Composed10 | Self::Tracking11) + } +} + /// Verifies the current Ticket-owned SQLite schema without executing DDL. pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { + verify_ticket_schema_shape(connection, TicketSchemaShape::Tracking11) +} + +/// Retained Workspace schema-66 migration produces the pre-classification v7 +/// Ticket shape. Validate that intermediate contract before the normal startup +/// path upgrades it; never require future tag tables inside that old transaction. +pub fn verify_pre_classification_ticket_schema(connection: &Connection) -> Result<()> { + verify_ticket_schema_shape(connection, TicketSchemaShape::Legacy7) +} + +/// Verifies retained six-state component 7 or tags-only component 8, without +/// treating historical workflow values as the current two-state case authority. +pub fn verify_legacy_sqlite_ticket_schema(connection: &Connection) -> Result<()> { + let applied = load_applied_migrations(connection)?; + let version = applied.keys().next().copied().unwrap_or_default(); + if !matches!(version, 7 | 8) { + return Err(TicketError::Sqlite( + "legacy Ticket schema requires component 7 or 8".into(), + )); + } + verify_ticket_schema_shape(connection, TicketSchemaShape::from_version(version)) +} + +fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) -> Result<()> { let mut diagnostics = Vec::new(); collect_table_diagnostics( @@ -337,23 +496,40 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { match load_applied_migrations(connection) { Ok(applied) => { - if let Err(error) = validate_applied_migrations(&applied) { + let history = if applied + == BTreeMap::from([(shape.version(), BASELINE_MIGRATION_NAME.to_owned())]) + || (matches!(shape, TicketSchemaShape::Tracking11) + && applied + == BTreeMap::from([ + (10, BASELINE_MIGRATION_NAME.to_owned()), + (11, "Ticket-owned assignment tracking".to_owned()), + ])) { + Ok(()) + } else { + Err(TicketError::Sqlite(format!( + "Ticket schema migration history must contain only the canonical version {} baseline marker", + shape.version() + ))) + }; + if let Err(error) = history { push_diagnostic(&mut diagnostics, error.to_string()); - } else if applied.len() != MIGRATIONS.len() { - push_diagnostic( - &mut diagnostics, - format!( - "Ticket schema is not current: found {} migration(s), expected {}", - applied.len(), - MIGRATIONS.len() - ), - ); } } Err(error) => push_diagnostic(&mut diagnostics, error.to_string()), } for (table, columns, foreign_keys) in [ + ("workspace_tags", TAG_COLUMNS, TICKET_FOREIGN_KEYS), + ( + "typed_ticket_tags", + TICKET_TAG_COLUMNS, + TICKET_TAG_FOREIGN_KEYS, + ), + ( + "workspace_tag_operations", + TAG_OPERATION_COLUMNS, + TICKET_FOREIGN_KEYS, + ), ("typed_tickets", TICKET_COLUMNS, TICKET_FOREIGN_KEYS), ("typed_ticket_targets", TARGET_COLUMNS, CHILD_FOREIGN_KEYS), ("typed_ticket_labels", LABEL_COLUMNS, CHILD_FOREIGN_KEYS), @@ -394,9 +570,47 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { CHILD_FOREIGN_KEYS, ), ] { + if !shape.tags() + && matches!( + table, + "workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations" + ) + { + continue; + } collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics); } + if shape.tags() { + collect_index_diagnostics( + connection, + "workspace_tags", + Some("workspace_tags_active_name"), + true, + &["workspace_id", "normalized_name"], + &mut diagnostics, + ); + collect_index_diagnostics( + connection, + "typed_ticket_tags", + Some("typed_ticket_tags_workspace_tag"), + false, + &["workspace_id", "tag_id", "ticket_id"], + &mut diagnostics, + ); + let tag_name_index_sql: Option = connection.query_row("SELECT sql FROM sqlite_master WHERE type='index' AND name='workspace_tags_active_name'", [], |row| row.get(0)).optional().map_err(sqlite_err)?; + if !tag_name_index_sql.is_some_and(|sql| { + sql.split_whitespace() + .collect::() + .to_lowercase() + .ends_with("wheredeleted=0") + }) { + push_diagnostic( + &mut diagnostics, + "workspace_tags_active_name must be partial WHERE deleted = 0".into(), + ); + } + } collect_index_diagnostics( connection, "typed_ticket_targets", @@ -448,10 +662,154 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics); } + if shape.cases() { + for (table, columns) in [ + ( + "typed_ticket_case_metadata", + vec![ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("closure_operation_key", "TEXT", false, 0), + ], + ), + ( + "typed_ticket_case_history", + vec![ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("ordinal", "INTEGER", true, 0), + column("operation_key", "TEXT", true, 3), + column("request_fingerprint", "TEXT", true, 0), + column("judgment_json", "TEXT", true, 0), + ], + ), + ( + "typed_ticket_legacy_workflow_snapshot", + vec![ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("snapshot_json", "TEXT", true, 0), + ], + ), + ] { + if let Err(error) = verify_table(connection, table, &columns, CHILD_FOREIGN_KEYS, false) + { + push_diagnostic(&mut diagnostics, error.to_string()); + } + } + collect_index_diagnostics( + connection, + "typed_ticket_case_history", + None, + true, + &["workspace_id", "ticket_id", "ordinal"], + &mut diagnostics, + ); + for name in [ + "typed_ticket_case_history_no_update", + "typed_ticket_case_history_no_delete", + "typed_ticket_legacy_workflow_no_update", + "typed_ticket_legacy_workflow_no_delete", + "typed_ticket_case_state_insert", + "typed_ticket_case_state_update", + ] { + let sql: Option = connection + .query_row( + "SELECT sql FROM sqlite_master WHERE type = 'trigger' AND name = ?1", + params![name], + |row| row.get(0), + ) + .optional() + .map_err(sqlite_err)?; + let canonical = include_str!("case_schema.sql") + .split("CREATE TRIGGER ") + .skip(1) + .find(|ddl| ddl.starts_with(name)) + .expect("canonical case trigger") + .split_once("END;") + .expect("trigger terminator") + .0; + let canonical = format!("CREATE TRIGGER {canonical}END"); + let normalize = |sql: &str| { + sql.trim_end_matches(';') + .split_whitespace() + .collect::() + .to_lowercase() + }; + match sql { + None => push_diagnostic( + &mut diagnostics, + format!("missing Ticket case trigger {name}"), + ), + Some(sql) if normalize(&sql) != normalize(&canonical) => push_diagnostic( + &mut diagnostics, + format!("invalid Ticket case trigger {name}"), + ), + Some(_) => {} + } + } + } else { + for table in [ + "typed_ticket_case_metadata", + "typed_ticket_case_history", + "typed_ticket_legacy_workflow_snapshot", + ] { + collect_absent_table_diagnostic(connection, table, &mut diagnostics); + } + } + if !shape.tags() { + for table in [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + ] { + collect_absent_table_diagnostic(connection, table, &mut diagnostics); + } + } + let invalid_states: i64 = connection.query_row( + if shape.cases() { + "SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open','closed')" + } else { + "SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('planning','ready','queued','inprogress','done','closed')" + }, [], |row| row.get(0)).map_err(sqlite_err)?; + if invalid_states != 0 { + push_diagnostic( + &mut diagnostics, + format!( + "component {} has {invalid_states} unsupported Ticket states", + shape.version() + ), + ); + } for table in OWNED_TABLES { + if !shape.tags() + && matches!( + *table, + "workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations" + ) + { + continue; + } + if !shape.cases() + && matches!( + *table, + "typed_ticket_case_metadata" + | "typed_ticket_case_history" + | "typed_ticket_legacy_workflow_snapshot" + ) + { + continue; + } collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); } + if matches!(shape, TicketSchemaShape::Tracking11) { + if let Err(error) = + crate::assignment_tracking::verify_assignment_tracking_schema(connection) + { + push_diagnostic(&mut diagnostics, error.to_string()); + } + } if diagnostics.is_empty() { Ok(()) } else { @@ -471,7 +829,93 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { connection .execute_batch(include_str!("latest_schema.sql")) - .map_err(sqlite_err) + .map_err(sqlite_err)?; + crate::assignment_tracking::migrate_assignment_tracking(connection) +} + +/// Retained 7/8 case migration: leave tags and historical receipts untouched. +fn migrate_legacy_cases(connection: &Connection) -> Result<()> { + let snapshots = { + let mut stmt = connection.prepare("SELECT workspace_id, ticket_id, workflow_state, workflow_state_explicit, status, queued_by, queued_at, resolution FROM typed_tickets").map_err(sqlite_err)?; + stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + crate::TicketLegacyWorkflowSnapshot { + workflow_state: row.get(2)?, + workflow_state_explicit: row.get::<_, i64>(3)? != 0, + status: row.get(4)?, + queued_by: row.get(5)?, + queued_at: row.get(6)?, + resolution: row.get(7)?, + }, + )) + }) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)? + }; + // Validate old authority before any writes; unknown states are not silently repaired. + for (_, _, snapshot) in &snapshots { + if !matches!( + snapshot.workflow_state.as_str(), + "planning" | "ready" | "queued" | "inprogress" | "done" | "closed" + ) { + return Err(TicketError::Sqlite(format!( + "unsupported legacy Ticket workflow state {:?}", + snapshot.workflow_state + ))); + } + } + // State guards are installed only after all six-state rows have been converted. + for (workspace_id, ticket_id, snapshot) in &snapshots { + let state = if matches!(snapshot.workflow_state.as_str(), "done" | "closed") { + "closed" + } else { + "open" + }; + connection.execute("UPDATE typed_tickets SET workflow_state = ?3, status = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2", params![workspace_id, ticket_id, state]).map_err(sqlite_err)?; + } + connection + .execute_batch(include_str!("case_schema.sql")) + .map_err(sqlite_err)?; + for (workspace_id, ticket_id, snapshot) in snapshots { + connection.execute("INSERT INTO typed_ticket_legacy_workflow_snapshot (workspace_id, ticket_id, snapshot_json) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, serde_json::to_string(&snapshot).map_err(sqlite_err)?]).map_err(sqlite_err)?; + if matches!(snapshot.workflow_state.as_str(), "done" | "closed") { + let key = format!("migration-v10:{ticket_id}"); + let judgment = crate::TicketCaseHistoryEntry { + operation_key: key.clone(), + previous_closure_operation_key: None, + // No typed evidence in schema7 establishes Completed. `done` is not success. + action: crate::TicketCaseAction::Close { + reason: crate::TicketClosureReason::Unknown, + }, + explanation: snapshot + .resolution + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| { + format!( + "Migrated legacy {} state; closure reason is unknown.", + snapshot.workflow_state + ) + }), + references: Vec::new(), + author: "ticket-schema-migration-v10".into(), + at: chrono::Utc::now().to_rfc3339(), + }; + connection.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) VALUES (?1, ?2, 0, ?3, 'migration-v10', ?4)", params![workspace_id, ticket_id, key, serde_json::to_string(&judgment).map_err(sqlite_err)?]).map_err(sqlite_err)?; + connection.execute("INSERT INTO typed_ticket_case_metadata (workspace_id, ticket_id, closure_operation_key) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, key]).map_err(sqlite_err)?; + } + } + Ok(()) +} +fn add_tag_schema(connection: &Connection) -> Result<()> { + // Use the exact canonical DDL rather than duplicating migration definitions. + let (_, tag_schema) = include_str!("latest_schema.sql") + .split_once("-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.") + .expect("canonical tag schema marker"); + connection.execute_batch(tag_schema).map_err(sqlite_err) } fn migrate_v6_to_v7(connection: &Connection) -> Result<()> { @@ -636,9 +1080,15 @@ fn load_applied_migrations(connection: &Connection) -> Result) -> Result<()> { let expected = BTreeMap::from([( LATEST_SQLITE_TICKET_SCHEMA_VERSION, - MIGRATIONS[0].name.to_string(), + BASELINE_MIGRATION_NAME.to_string(), )]); - if applied == &expected { + if applied == &expected + || applied + == &BTreeMap::from([ + (10, BASELINE_MIGRATION_NAME.to_owned()), + (11, "Ticket-owned assignment tracking".to_owned()), + ]) + { Ok(()) } else { Err(TicketError::Sqlite(format!( @@ -1026,7 +1476,9 @@ mod tests { #[test] fn migrates_single_target_rows_to_ordered_read_write_targets() { let connection = Connection::open_in_memory().unwrap(); - create_latest_ticket_schema(&connection).unwrap(); + connection + .execute_batch(include_str!("../tests/fixtures/schema7.sql")) + .unwrap(); connection .execute_batch( "DROP TABLE typed_ticket_targets; @@ -1137,10 +1589,137 @@ mod tests { assert!(!migrated_attributes.contains_key("ref_selector")); assert_eq!( load_applied_migrations(&connection).unwrap(), - BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())]) + BTreeMap::from([ + (10, BASELINE_MIGRATION_NAME.to_owned()), + (11, "Ticket-owned assignment tracking".to_owned()) + ]) ); } + #[test] + fn migrates_v7_to_identical_fresh_schema_without_touching_legacy_labels() { + let upgraded = Connection::open_in_memory().unwrap(); + let legacy_sql = include_str!("../tests/fixtures/schema7.sql"); + upgraded.execute_batch(legacy_sql).unwrap(); + upgraded.execute_batch( + "CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL); + INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'old'); + INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES ('ws','ticket','ticket','Title','open','task','P2','body','ready',1); + INSERT INTO typed_ticket_labels VALUES ('ws','ticket',0,' Legacy Label ');" + ).unwrap(); + migrate_sqlite_ticket_schema(&upgraded).unwrap(); + verify_sqlite_ticket_schema(&upgraded).unwrap(); + let fresh = Connection::open_in_memory().unwrap(); + migrate_sqlite_ticket_schema(&fresh).unwrap(); + let schema = |conn: &Connection| { + let mut statement = conn.prepare("SELECT type,name,tbl_name,sql FROM sqlite_master WHERE name NOT LIKE 'sqlite_%' ORDER BY type,name").unwrap(); + statement + .query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)? + .map(|sql| sql.split_whitespace().collect::()), + )) + }) + .unwrap() + .collect::, _>>() + .unwrap() + }; + assert_eq!(schema(&upgraded), schema(&fresh)); + let label: String = upgraded + .query_row("SELECT label FROM typed_ticket_labels", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(label, " Legacy Label "); + let tags: i64 = upgraded + .query_row("SELECT COUNT(*) FROM workspace_tags", [], |row| row.get(0)) + .unwrap(); + assert_eq!(tags, 0); // Legacy labels are never promoted to catalog tags. + migrate_sqlite_ticket_schema(&upgraded).unwrap(); + assert_eq!(schema(&upgraded), schema(&fresh)); + } + + #[test] + fn tag_schema_verification_detects_drift_without_repair() { + for (drift, expected) in [ + ("DROP TABLE typed_ticket_tags", "typed_ticket_tags"), + ( + "ALTER TABLE workspace_tags RENAME COLUMN last_operation_key TO wrong_observed_key", + "last_operation_key", + ), + ( + "ALTER TABLE workspace_tag_operations DROP COLUMN result_json", + "result_json", + ), + ( + "DROP INDEX typed_ticket_tags_workspace_tag", + "typed_ticket_tags_workspace_tag", + ), + ( + "DROP INDEX workspace_tags_active_name; CREATE UNIQUE INDEX workspace_tags_active_name ON workspace_tags(workspace_id, normalized_name)", + "partial WHERE deleted = 0", + ), + ( + "DROP TABLE typed_ticket_tags; CREATE TABLE typed_ticket_tags (workspace_id TEXT NOT NULL,ticket_id TEXT NOT NULL,tag_id TEXT NOT NULL, PRIMARY KEY(workspace_id,ticket_id,tag_id)); CREATE INDEX typed_ticket_tags_workspace_tag ON typed_ticket_tags(workspace_id,tag_id,ticket_id)", + "missing foreign key", + ), + ] { + let connection = Connection::open_in_memory().unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + connection.execute_batch(drift).unwrap(); + let error = verify_sqlite_ticket_schema(&connection).unwrap_err(); + assert!( + error.to_string().contains(expected), + "after {drift}: {error}" + ); + assert!(migrate_sqlite_ticket_schema(&connection).is_err()); + } + } + + #[test] + fn tag_schema_rejects_cross_workspace_links_and_preserves_tombstones() { + let connection = Connection::open_in_memory().unwrap(); + connection + .pragma_update(None, "foreign_keys", "ON") + .unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + connection.execute_batch( + "INSERT INTO typed_tickets (workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES ('one','ticket','ticket','Title','open','task','P2','body','open',1); + INSERT INTO workspace_tags VALUES ('two','tag','Name','name','create',0);" + ).unwrap(); + assert!( + connection + .execute( + "INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')", + [] + ) + .is_err() + ); + connection + .execute( + "INSERT INTO workspace_tags VALUES ('one','tag','Name','name','create',0)", + [], + ) + .unwrap(); + connection + .execute( + "INSERT INTO typed_ticket_tags VALUES ('one','ticket','tag')", + [], + ) + .unwrap(); + assert!( + connection + .execute("DELETE FROM workspace_tags WHERE workspace_id='one'", []) + .is_err() + ); + verify_sqlite_ticket_schema(&connection).unwrap(); + } + #[test] fn rejects_unknown_future_migration_history_without_changing_schema() { let connection = Connection::open_in_memory().unwrap(); @@ -1155,7 +1734,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 7 baseline marker" + "migration history must contain only the canonical version 11 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -1177,7 +1756,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 7 baseline marker" + "migration history must contain only the canonical version 11 baseline marker" )); assert!(!table_exists(&connection, "typed_tickets").unwrap()); } diff --git a/crates/ticket/src/tags.rs b/crates/ticket/src/tags.rs new file mode 100644 index 000000000..a58ee09a9 --- /dev/null +++ b/crates/ticket/src/tags.rs @@ -0,0 +1,337 @@ +//! Workspace tag identity, display-name policy, and transactional SQLite authority. +//! +//! Display names use Rust `str::trim` (Unicode whitespace), then Unicode +//! `str::to_lowercase` for active-name uniqueness. No NFC/NFKC normalization or +//! full case folding is performed: canonically equivalent spellings can differ. +//! Any control character in the input is rejected, including trimmed edges. +//! The trimmed display name must be nonempty and at most 128 UTF-8 bytes. +//! IDs are immutable and Workspace-scoped; names never grant execution authority. +//! Rename preserves memberships. Delete atomically tombstones the identity and removes +//! current memberships, retaining historical identity/name snapshots. Reusing a deleted +//! name creates a new ID. Legacy labels are not classification authority. +//! +//! Rename/delete compare the observed tag operation key; membership replacement compares +//! the full active ID set, not a content digest or timestamp. Returning to the same set is +//! intentionally equivalent; an observed deleted ID is stale. Delete and attachment share +//! an immediate transaction, so missing, deleted, or foreign identities cannot dangle. +//! +//! Exact replay compares full input, target, and authenticated identity before live checks, +//! returning the saved response without another audit event. It is not fresh observation: +//! reread before a new edit, and retain the original key/input after an uncertain outcome. +//! A no-op set saves a receipt without a tags-changed event. Tag edits change neither +//! editable requirements nor case judgments, handling, grants, routing, or MR evidence. + +use super::*; + +#[cfg(test)] +#[path = "tags_tests.rs"] +mod tests; + +/// Workspace-scoped immutable identity with a mutable display name. +/// Names are Unicode-whitespace trimmed, limited to 128 UTF-8 bytes, and +/// deduplicated among active tags using Rust Unicode lowercase (not full case +/// folding or NFC/NFKC). Empty names and any input control characters are invalid. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct WorkspaceTag { + pub workspace_id: String, + pub id: String, + pub name: String, + pub last_operation_key: String, + pub deleted: bool, +} + +/// Active tags in ascending ID order, strictly after `after`; default/max 200. +/// Larger limits are clamped to 200; zero returns an empty page. +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TagListRequest { + pub after: Option, + #[schemars(range(min = 0, max = 200))] + pub limit: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct WorkspaceTagMutation { + pub operation_key: String, + pub action: WorkspaceTagAction, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(tag = "kind", rename_all = "snake_case", deny_unknown_fields)] +pub enum WorkspaceTagAction { + Create { + name: String, + }, + Rename { + tag_id: String, + expected_last_operation_key: String, + name: String, + }, + Delete { + tag_id: String, + expected_last_operation_key: String, + }, +} + +/// Compare entire active ID set, then replace it atomically. IDs must be unique; +/// input order is immaterial to CAS, but remains significant for exact replay. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketTagSetRequest { + pub expected_tag_ids: Vec, + pub tag_ids: Vec, + pub operation_key: String, +} + +fn normalize_name(input: &str) -> Result<(String, String)> { + let name = input.trim(); + if name.is_empty() || name.len() > 128 || input.chars().any(char::is_control) { + return Err(TicketError::Conflict("tag name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into())); + } + Ok((name.to_owned(), name.to_lowercase())) +} + +fn sorted_ids(ids: &[String]) -> Result> { + if ids.len() > 100 || ids.iter().any(|id| id.len() > 128) { + return Err(TicketError::Conflict( + "tag sets require at most 100 IDs of at most 128 bytes".into(), + )); + } + let mut sorted = ids.to_vec(); + sorted.sort(); + if sorted.iter().any(|id| id.trim().is_empty()) + || sorted.windows(2).any(|pair| pair[0] == pair[1]) + { + return Err(TicketError::Conflict( + "tag IDs must be nonempty and unique".into(), + )); + } + Ok(sorted) +} + +fn read_tag(row: &rusqlite::Row<'_>) -> rusqlite::Result { + Ok(WorkspaceTag { + workspace_id: row.get(0)?, + id: row.get(1)?, + name: row.get(2)?, + last_operation_key: row.get(3)?, + deleted: row.get::<_, i64>(4)? != 0, + }) +} + +impl SqliteTicketBackend { + pub(crate) fn load_ticket_tags( + &self, + conn: &Connection, + ticket_id: &str, + ) -> Result> { + let mut statement = conn.prepare("SELECT tag.workspace_id,tag.tag_id,tag.name,tag.last_operation_key,tag.deleted FROM workspace_tags AS tag JOIN typed_ticket_tags AS link ON link.workspace_id=tag.workspace_id AND link.tag_id=tag.tag_id WHERE link.workspace_id=?1 AND link.ticket_id=?2 AND tag.deleted=0 ORDER BY tag.tag_id").map_err(sqlite_err)?; + statement + .query_map(params![self.workspace_id, ticket_id], read_tag) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err) + } + + fn load_tag(&self, conn: &Connection, id: &str) -> Result { + conn.query_row("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2", params![self.workspace_id,id], read_tag).optional().map_err(sqlite_err)?.ok_or_else(|| TicketError::NotFound(format!("workspace tag {id}"))) + } + + fn tag_input(&self, kind: &str, target: &str, request: &impl Serialize) -> Result { + // Bind authenticated identity, not its mutable assignment/role context: + // a receipt is still the same request after the Worker is reassigned. + // The complete original context is retained separately in the audit row. + let identity: BTreeMap<_, _> = self + .event_attributes + .iter() + .filter(|(key, _)| { + matches!( + key.as_str(), + "source_account_id" + | "source_user_id" + | "source_runtime_id" + | "source_worker_id" + ) + }) + .collect(); + serde_json::to_string(&(kind, target, request, identity)).map_err(sqlite_err) + } + + fn replay_tag_operation( + &self, + conn: &Connection, + key: &str, + input: &str, + ) -> Result> { + validate_required_event_value("operation_key", key)?; + let receipt: Option<(String,String)> = conn.query_row("SELECT input_json,result_json FROM workspace_tag_operations WHERE workspace_id=?1 AND operation_key=?2",params![self.workspace_id,key],|row| Ok((row.get(0)?,row.get(1)?))).optional().map_err(sqlite_err)?; + match receipt { + Some((original, result)) if original == input => { + Ok(Some(serde_json::from_str(&result).map_err(sqlite_err)?)) + } + Some(_) => Err(TicketError::OperationFingerprintMismatch { + operation_key: key.to_owned(), + }), + None => Ok(None), + } + } + + fn record_tag_operation( + &self, + conn: &Connection, + key: &str, + kind: &str, + target: &str, + input: &str, + snapshots: (&impl Serialize, &impl Serialize), + ) -> Result<()> { + conn.execute("INSERT INTO workspace_tag_operations (workspace_id,operation_key,kind,target_id,input_json,before_json,result_json,at,source_context_json) VALUES (?1,?2,?3,?4,?5,?6,?7,?8,?9)", params![self.workspace_id,key,kind,target,input,serde_json::to_string(snapshots.0).map_err(sqlite_err)?,serde_json::to_string(snapshots.1).map_err(sqlite_err)?,now_utc(),serde_json::to_string(&self.event_attributes).map_err(sqlite_err)?]).map_err(sqlite_err)?; + Ok(()) + } + + fn audit_ticket_tags( + &self, + conn: &Connection, + ticket_id: &str, + key: &str, + before: &[WorkspaceTag], + after: &[WorkspaceTag], + ) -> Result<()> { + self.insert_event( + conn, + ticket_id, + &TicketEvent { + kind: TicketEventKind::TagsChanged, + author: Some(default_author()), + at: Some(now_utc()), + status: None, + from: None, + to: None, + reason: None, + state_field: None, + heading: Some("Tags changed".into()), + body: MarkdownText::new("Ticket tags changed"), + references: Vec::new(), + attributes: BTreeMap::from([ + ("operation_key".into(), key.to_owned()), + ( + "tags_before".into(), + serde_json::to_string(before).map_err(sqlite_err)?, + ), + ( + "tags_after".into(), + serde_json::to_string(after).map_err(sqlite_err)?, + ), + ]), + }, + ) + } + + pub(crate) fn list_tags(&self, request: TagListRequest) -> Result> { + self.with_read(|conn| { + let mut statement = conn.prepare("SELECT workspace_id,tag_id,name,last_operation_key,deleted FROM workspace_tags WHERE workspace_id=?1 AND deleted=0 AND (?2 IS NULL OR tag_id>?2) ORDER BY tag_id LIMIT ?3").map_err(sqlite_err)?; + statement.query_map(params![self.workspace_id,request.after,request.limit.unwrap_or(200).min(200) as i64],read_tag).map_err(sqlite_err)?.collect::,_>>().map_err(sqlite_err) + }) + } + + pub(crate) fn mutate_tag(&self, request: WorkspaceTagMutation) -> Result { + self.with_write(|conn| { + let input = self.tag_input("mutate_workspace_tag", "", &request)?; + if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); } + let (before, result, kind) = match &request.action { + WorkspaceTagAction::Create { name } => { + let (name, normalized) = normalize_name(name)?; + self.require_unique_tag_name(conn,&normalized,None)?; + let millis = unix_epoch_millis_now().map_err(|error| TicketError::Conflict(format!("failed to read tag ID timestamp: {error}")))?; + let id = allocate_record_id(millis, |candidate| { + conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,candidate],|_| Ok(())).optional().map(|row| row.is_some()).unwrap_or(true) + }).map_err(|error| TicketError::Conflict(error.to_string()))?; + let tag = WorkspaceTag { workspace_id: self.workspace_id.clone(), id, name, last_operation_key: request.operation_key.clone(), deleted: false }; + conn.execute("INSERT INTO workspace_tags (workspace_id,tag_id,name,normalized_name,last_operation_key,deleted) VALUES (?1,?2,?3,?4,?5,0)",params![tag.workspace_id,tag.id,tag.name,normalized,tag.last_operation_key]).map_err(sqlite_err)?; + (None,tag,"create") + } + WorkspaceTagAction::Rename { tag_id,expected_last_operation_key,.. } | WorkspaceTagAction::Delete { tag_id,expected_last_operation_key } => { + let previous = self.load_tag(conn,tag_id)?; + if previous.deleted || &previous.last_operation_key != expected_last_operation_key { + return Err(TicketError::Conflict(format!("stale or deleted tag {tag_id}: expected operation {expected_last_operation_key:?}, actual {:?}",previous.last_operation_key))); + } + let mut tag = previous.clone(); + tag.last_operation_key = request.operation_key.clone(); + let kind = if let WorkspaceTagAction::Rename { name,.. } = &request.action { + let (name,normalized) = normalize_name(name)?; + self.require_unique_tag_name(conn,&normalized,Some(tag_id))?; + tag.name = name; + conn.execute("UPDATE workspace_tags SET name=?3,normalized_name=?4,last_operation_key=?5 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,tag.name,normalized,request.operation_key]).map_err(sqlite_err)?; + "rename" + } else { + // Capture active snapshots before tombstoning; links and audit are + // cleared/written under the same immediate lock as attachments. + let ticket_ids = { + let mut stmt = conn.prepare("SELECT ticket_id FROM typed_ticket_tags WHERE workspace_id=?1 AND tag_id=?2 ORDER BY ticket_id").map_err(sqlite_err)?; + stmt.query_map(params![self.workspace_id,tag_id],|row| row.get::<_,String>(0)).map_err(sqlite_err)?.collect::,_>>().map_err(sqlite_err)? + }; + for ticket_id in ticket_ids { + let before = self.load_ticket_tags(conn,&ticket_id)?; + let after: Vec<_> = before.iter().filter(|tag| &tag.id != tag_id).cloned().collect(); + conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2 AND tag_id=?3",params![self.workspace_id,ticket_id,tag_id]).map_err(sqlite_err)?; + self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?; + } + tag.deleted = true; + conn.execute("UPDATE workspace_tags SET deleted=1,last_operation_key=?3 WHERE workspace_id=?1 AND tag_id=?2",params![self.workspace_id,tag_id,request.operation_key]).map_err(sqlite_err)?; + "delete" + }; + (Some(previous),tag,kind) + } + }; + self.record_tag_operation(conn,&request.operation_key,kind,&result.id,&input,(&before,&result))?; + Ok(result) + }) + } + + fn require_unique_tag_name( + &self, + conn: &Connection, + normalized: &str, + except: Option<&str>, + ) -> Result<()> { + let duplicate = conn.query_row("SELECT 1 FROM workspace_tags WHERE workspace_id=?1 AND normalized_name=?2 AND deleted=0 AND (?3 IS NULL OR tag_id<>?3)",params![self.workspace_id,normalized,except],|_| Ok(())).optional().map_err(sqlite_err)?.is_some(); + if duplicate { + return Err(TicketError::Conflict( + "active workspace tag name already exists".into(), + )); + } + Ok(()) + } + + pub(crate) fn set_tags( + &self, + id: TicketIdOrSlug, + request: TicketTagSetRequest, + ) -> Result> { + self.with_write(|conn| { + // Preserve exact selector input, not just its resolved identity. + let input = self.tag_input("set_ticket_tags", "", &(&id,&request))?; + if let Some(result) = self.replay_tag_operation(conn,&request.operation_key,&input)? { return Ok(result); } + let ticket_id = self.resolve_ticket_id(conn,id)?; + self.require_active_source_assignment(conn,&ticket_id)?; + let before = self.load_ticket_tags(conn,&ticket_id)?; + let expected = sorted_ids(&request.expected_tag_ids)?; + let desired = sorted_ids(&request.tag_ids)?; + if before.iter().map(|tag| tag.id.clone()).collect::>() != expected { + return Err(TicketError::Conflict("stale Ticket tag set".into())); + } + let after = desired.iter().map(|id| { + let tag = self.load_tag(conn,id)?; + if tag.deleted { return Err(TicketError::Conflict(format!("workspace tag {id} is deleted"))); } + Ok(tag) + }).collect::>>()?; + conn.execute("DELETE FROM typed_ticket_tags WHERE workspace_id=?1 AND ticket_id=?2",params![self.workspace_id,ticket_id]).map_err(sqlite_err)?; + for tag in &after { + conn.execute("INSERT INTO typed_ticket_tags (workspace_id,ticket_id,tag_id) VALUES (?1,?2,?3)",params![self.workspace_id,ticket_id,tag.id]).map_err(sqlite_err)?; + } + if before != after { self.audit_ticket_tags(conn,&ticket_id,&request.operation_key,&before,&after)?; } + self.record_tag_operation(conn,&request.operation_key,"set_ticket_tags",&ticket_id,&input,(&before,&after))?; + Ok(after) + }) + } +} diff --git a/crates/ticket/src/tags_tests.rs b/crates/ticket/src/tags_tests.rs new file mode 100644 index 000000000..24c28e682 --- /dev/null +++ b/crates/ticket/src/tags_tests.rs @@ -0,0 +1,804 @@ +use super::*; +use tempfile::tempdir; + +fn create_tag(backend: &SqliteTicketBackend, key: &str, name: &str) -> WorkspaceTag { + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: key.into(), + action: WorkspaceTagAction::Create { name: name.into() }, + }) + .unwrap() +} +fn rename_tag( + backend: &SqliteTicketBackend, + key: &str, + tag: &WorkspaceTag, + name: &str, +) -> WorkspaceTag { + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: key.into(), + action: WorkspaceTagAction::Rename { + tag_id: tag.id.clone(), + expected_last_operation_key: tag.last_operation_key.clone(), + name: name.into(), + }, + }) + .unwrap() +} +fn delete_request(key: &str, tag: &WorkspaceTag) -> WorkspaceTagMutation { + WorkspaceTagMutation { + operation_key: key.into(), + action: WorkspaceTagAction::Delete { + tag_id: tag.id.clone(), + expected_last_operation_key: tag.last_operation_key.clone(), + }, + } +} +fn set_request( + key: &str, + expected: &[WorkspaceTag], + desired: &[WorkspaceTag], +) -> TicketTagSetRequest { + TicketTagSetRequest { + expected_tag_ids: expected.iter().map(|t| t.id.clone()).collect(), + tag_ids: desired.iter().map(|t| t.id.clone()).collect(), + operation_key: key.into(), + } +} +fn audit_count(backend: &SqliteTicketBackend) -> i64 { + backend + .open_connection() + .unwrap() + .query_row( + "SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1", + params![backend.workspace_id], + |row| row.get(0), + ) + .unwrap() +} + +#[test] +fn tag_names_have_documented_unicode_lowercase_not_casefold_policy() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "a", "\u{2003}Ä BC\u{2003}"); + assert_eq!(tag.name, "Ä BC"); + for name in ["ä bc", " Ä BC "] { + assert!( + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: "duplicate".into(), + action: WorkspaceTagAction::Create { name: name.into() } + }) + .is_err() + ); + } + for name in [ + "", + " \u{2003}", + "bad\nname", + "\tname", + "a\0", + &"é".repeat(65), + ] { + assert!( + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: "invalid".into(), + action: WorkspaceTagAction::Create { name: name.into() } + }) + .is_err() + ); + } + create_tag(&backend, "boundary", &"é".repeat(64)); + create_tag(&backend, "sharp", "ß"); + create_tag(&backend, "ss", "SS"); // Lowercase, not full case folding. + create_tag(&backend, "composed", "é"); + create_tag(&backend, "decomposed", "e\u{301}"); // No Unicode normalization. + assert_eq!(audit_count(&backend), 6); +} + +#[test] +fn tag_rename_delete_cas_and_name_conflicts_leave_no_audit_or_changes() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let a = create_tag(&backend, "a", "A"); + let b = create_tag(&backend, "b", "B"); + let renamed = rename_tag(&backend, "rename", &a, "New"); + assert_eq!(a.id, renamed.id); + for action in [ + WorkspaceTagAction::Rename { + tag_id: a.id.clone(), + expected_last_operation_key: "a".into(), + name: "Stale".into(), + }, + WorkspaceTagAction::Delete { + tag_id: a.id.clone(), + expected_last_operation_key: "a".into(), + }, + WorkspaceTagAction::Rename { + tag_id: renamed.id.clone(), + expected_last_operation_key: "rename".into(), + name: " b ".into(), + }, + WorkspaceTagAction::Rename { + tag_id: renamed.id.clone(), + expected_last_operation_key: "rename".into(), + name: "\n".into(), + }, + ] { + assert!( + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: "rejected".into(), + action + }) + .is_err() + ); + } + assert_eq!(audit_count(&backend), 3); + let active = backend + .list_workspace_tags(TagListRequest::default()) + .unwrap(); + assert!(active.contains(&renamed) && active.contains(&b)); +} + +#[test] +fn workspace_mutation_exact_replay_returns_stored_snapshot_before_cas() { + let dir = tempdir().unwrap(); + let db = dir.path().join("tags.db"); + let backend = SqliteTicketBackend::open(&db, "ws") + .unwrap() + .with_event_attributes(BTreeMap::from([( + "source_worker_id".into(), + "actor-a".into(), + )])); + let request = WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { name: " A ".into() }, + }; + let original = backend.mutate_workspace_tag(request.clone()).unwrap(); + let renamed = rename_tag(&backend, "rename", &original, "B"); + let deletion = delete_request("delete", &renamed); + let tombstone = backend.mutate_workspace_tag(deletion.clone()).unwrap(); + assert!(tombstone.deleted); + assert_eq!( + backend.mutate_workspace_tag(request.clone()).unwrap(), + original + ); + assert_eq!( + backend.mutate_workspace_tag(deletion.clone()).unwrap(), + tombstone + ); + let restarted = SqliteTicketBackend::open_verified(&db, "ws") + .unwrap() + .with_event_attributes(backend.event_attributes.clone()); + assert_eq!( + restarted.mutate_workspace_tag(request.clone()).unwrap(), + original + ); + let other_actor = restarted.with_event_attributes(BTreeMap::from([( + "source_worker_id".into(), + "actor-b".into(), + )])); + assert!(matches!( + other_actor.mutate_workspace_tag(request), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert!(matches!( + backend.mutate_workspace_tag(WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { name: "A".into() } + }), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(audit_count(&backend), 3); + assert!( + backend + .list_workspace_tags(TagListRequest::default()) + .unwrap() + .is_empty() + ); +} + +#[test] +fn tag_deletion_detaches_every_ticket_keeps_audit_snapshots_and_recreate_new_id() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "create", "Old name"); + let mut ids = Vec::new(); + for i in 0..2 { + let id = backend + .create(NewTicket::new(format!("ticket {i}"))) + .unwrap() + .id; + backend + .set_ticket_tags( + id.clone().into(), + set_request(&format!("attach-{i}"), &[], std::slice::from_ref(&tag)), + ) + .unwrap(); + ids.push(id); + } + let renamed = rename_tag(&backend, "rename", &tag, "New name"); + backend + .mutate_workspace_tag(delete_request("delete", &renamed)) + .unwrap(); + let replacement = create_tag(&backend, "recreate", "new NAME"); + assert_ne!(replacement.id, tag.id); + for id in ids { + let ticket = backend.show(id.into()).unwrap(); + assert!(ticket.tags.is_empty()); + let events: Vec<_> = ticket + .events + .iter() + .filter(|e| e.kind == TicketEventKind::TagsChanged) + .collect(); + assert_eq!(events.len(), 2); + let first: Vec = + serde_json::from_str(&events[0].attributes["tags_after"]).unwrap(); + assert_eq!(first, vec![tag.clone()]); + let before_delete: Vec = + serde_json::from_str(&events[1].attributes["tags_before"]).unwrap(); + assert_eq!(before_delete, vec![renamed.clone()]); + assert_eq!(events[1].attributes["tags_after"], "[]"); + } + let conn = backend.open_connection().unwrap(); + let deleted: i64 = conn + .query_row( + "SELECT deleted FROM workspace_tags WHERE workspace_id='ws' AND tag_id=?1", + params![tag.id], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(deleted, 1); + assert_eq!(audit_count(&backend), 6); +} + +#[test] +fn ticket_tag_set_compares_whole_set_validates_ids_and_replays_historical_result() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let a = create_tag(&backend, "a", "A"); + let b = create_tag(&backend, "b", "B"); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let request = set_request("set", &[], &[b.clone(), a.clone()]); + let stored = backend + .set_ticket_tags(id.clone().into(), request.clone()) + .unwrap(); + assert!(stored.windows(2).all(|pair| pair[0].id < pair[1].id)); + for bad in [ + set_request("bad", std::slice::from_ref(&a), &[]), + TicketTagSetRequest { + expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(), + tag_ids: vec![a.id.clone(), a.id.clone()], + operation_key: "bad".into(), + }, + TicketTagSetRequest { + expected_tag_ids: stored.iter().map(|t| t.id.clone()).collect(), + tag_ids: vec!["missing".into()], + operation_key: "bad".into(), + }, + ] { + assert!(backend.set_ticket_tags(id.clone().into(), bad).is_err()); + } + assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored); + let renamed = rename_tag(&backend, "rename", &a, "Renamed"); + backend + .mutate_workspace_tag(delete_request("delete", &renamed)) + .unwrap(); + assert_eq!( + backend + .set_ticket_tags(id.clone().into(), request.clone()) + .unwrap(), + stored + ); + let mut changed = request.clone(); + changed.tag_ids.reverse(); + assert!(matches!( + backend.set_ticket_tags(id.clone().into(), changed), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + let current = backend.show(id.clone().into()).unwrap(); + assert_eq!(current.tags, vec![b]); + assert_eq!( + current + .events + .iter() + .filter(|e| e.kind == TicketEventKind::TagsChanged) + .count(), + 2 + ); + let other_actor = backend.clone().with_event_attributes(BTreeMap::from([( + "source_worker_id".into(), + "actor-b".into(), + )])); + assert!(matches!( + other_actor.set_ticket_tags(id.into(), request), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(audit_count(&backend), 5); +} + +#[test] +fn tags_and_operation_keys_are_workspace_scoped() { + let dir = tempdir().unwrap(); + let db = dir.path().join("tags.db"); + let one = SqliteTicketBackend::open(&db, "one").unwrap(); + let two = SqliteTicketBackend::open(&db, "two").unwrap(); + let a = create_tag(&one, "same-key", "Same name"); + let b = create_tag(&two, "same-key", "Same name"); + let ticket = two.create(NewTicket::new("ticket")).unwrap().id; + assert!( + two.mutate_workspace_tag(delete_request("delete", &a)) + .is_err() + ); + assert!( + two.set_ticket_tags(ticket.clone().into(), set_request("attach", &[], &[a])) + .is_err() + ); + two.set_ticket_tags( + ticket.into(), + set_request("attach", &[], std::slice::from_ref(&b)), + ) + .unwrap(); + assert_eq!( + one.list_workspace_tags(TagListRequest::default()) + .unwrap() + .len(), + 1 + ); + assert_eq!( + two.list_workspace_tags(TagListRequest::default()).unwrap(), + vec![b] + ); + assert_eq!(audit_count(&one), 1); +} + +#[test] +fn tags_do_not_change_content_workflow_queue_targets_or_legacy_labels_in_any_state() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "tag", "open"); + let closed_tag = create_tag(&backend, "closed-tag", "closed"); + for (i, state) in [TicketState::Open, TicketState::Closed] + .into_iter() + .enumerate() + { + let mut new = NewTicket::new(format!("ticket {i}")); + new.workflow_state = Some(state); + new.labels = vec!["legacy label".into()]; + new.queued_by = Some("user".into()); + new.queued_at = Some("yesterday".into()); + let id = backend.create(new).unwrap().id; + let before = backend.show(id.clone().into()).unwrap(); + backend + .set_ticket_tags( + id.clone().into(), + set_request(&format!("set-{i}"), &[], &[tag.clone(), closed_tag.clone()]), + ) + .unwrap(); + let after = backend.show(id.clone().into()).unwrap(); + assert_eq!(before.meta, after.meta); + assert_eq!(before.document, after.document); + assert_eq!(before.resolution, after.resolution); + assert_eq!( + ticket_content_digest(&before), + ticket_content_digest(&after) + ); + assert_eq!(before.closure_operation_key, after.closure_operation_key); + assert_eq!(before.current_closure, after.current_closure); + assert_eq!(before.case_history, after.case_history); + assert_eq!( + before.legacy_workflow_snapshot, + after.legacy_workflow_snapshot + ); + let mut expected_tags = vec![tag.clone(), closed_tag.clone()]; + expected_tags.sort_by(|a, b| a.id.cmp(&b.id)); + assert_eq!(after.tags, expected_tags); + let summary = backend + .list(TicketListQuery::all()) + .unwrap() + .into_iter() + .find(|s| s.id == id) + .unwrap(); + assert_eq!(summary.tags, after.tags); + let projection = backend.list_workspace_projection(200).unwrap(); + assert_eq!( + projection + .items + .iter() + .find(|s| s.summary.id == id) + .unwrap() + .summary + .tags, + after.tags + ); + let page = backend + .list_workspace_projection_page(SqliteTicketListPageQuery { + limit: 200, + ..Default::default() + }) + .unwrap(); + assert_eq!( + page.items + .iter() + .find(|s| s.summary.id == id) + .unwrap() + .summary + .tags, + after.tags + ); + } +} + +#[test] +fn tag_list_has_bounded_id_keyset_order_and_excludes_deleted() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + // Fixture insertion avoids spending this query test on 205 mutation transactions. + backend + .with_write(|conn| { + for i in 0..205 { + conn.execute( + "INSERT INTO workspace_tags VALUES ('ws',?1,?1,?1,'fixture',0)", + params![format!("tag-{i:03}")], + ) + .map_err(sqlite_err)?; + } + Ok(()) + }) + .unwrap(); + let first = backend + .list_workspace_tags(TagListRequest { + after: None, + limit: Some(usize::MAX), + }) + .unwrap(); + assert_eq!(first.len(), 200); + assert_eq!(first[0].id, "tag-000"); + assert_eq!(first[199].id, "tag-199"); + assert_eq!( + backend + .list_workspace_tags(TagListRequest::default()) + .unwrap(), + first + ); + backend + .mutate_workspace_tag(delete_request("delete", &first[0])) + .unwrap(); + let tail = backend + .list_workspace_tags(TagListRequest { + after: Some("tag-199".into()), + limit: Some(200), + }) + .unwrap(); + assert_eq!(tail.len(), 5); + assert!( + backend + .list_workspace_tags(TagListRequest { + after: None, + limit: Some(0) + }) + .unwrap() + .is_empty() + ); + assert!( + backend + .list_workspace_tags(TagListRequest { + after: None, + limit: Some(1) + }) + .unwrap() + .iter() + .all(|t| t.id == "tag-001") + ); +} + +#[test] +fn failed_tag_mutation_hook_rolls_back_links_tombstone_audit_and_receipt() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "create", "Tag"); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let failing = backend.clone().with_mutation_hook(Arc::new(|_, _| { + Err(TicketError::Conflict("test hook".into())) + })); + let attach = set_request("attach", &[], std::slice::from_ref(&tag)); + assert!( + failing + .set_ticket_tags(id.clone().into(), attach.clone()) + .is_err() + ); + assert!(backend.show(id.clone().into()).unwrap().tags.is_empty()); + assert_eq!(audit_count(&backend), 1); + backend.set_ticket_tags(id.clone().into(), attach).unwrap(); + let before = backend.show(id.clone().into()).unwrap(); + let deletion = delete_request("delete", &tag); + assert!(failing.mutate_workspace_tag(deletion.clone()).is_err()); + assert_eq!(backend.show(id.into()).unwrap(), before); + assert_eq!( + backend + .list_workspace_tags(TagListRequest::default()) + .unwrap(), + vec![tag] + ); + assert_eq!(audit_count(&backend), 2); + backend.mutate_workspace_tag(deletion).unwrap(); + assert_eq!(audit_count(&backend), 3); +} + +#[test] +fn concurrent_tag_replay_and_delete_attach_are_serialized() { + use std::sync::Barrier; + use std::thread; + let dir = tempdir().unwrap(); + let db = dir.path().join("tags.db"); + let backend = SqliteTicketBackend::open(&db, "ws").unwrap(); + let barrier = Arc::new(Barrier::new(3)); + let mut joins = Vec::new(); + for _ in 0..2 { + let backend = backend.clone(); + let barrier = barrier.clone(); + joins.push(thread::spawn(move || { + barrier.wait(); + create_tag(&backend, "create", "Tag") + })); + } + barrier.wait(); + let tag = joins.remove(0).join().unwrap(); + assert_eq!(joins.remove(0).join().unwrap(), tag); + assert_eq!(audit_count(&backend), 1); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let barrier = Arc::new(Barrier::new(3)); + let attach = { + let backend = backend.clone(); + let barrier = barrier.clone(); + let tag = tag.clone(); + let id = id.clone(); + thread::spawn(move || { + barrier.wait(); + backend.set_ticket_tags(id.into(), set_request("attach", &[], &[tag])) + }) + }; + let delete = { + let backend = backend.clone(); + let barrier = barrier.clone(); + let tag = tag.clone(); + thread::spawn(move || { + barrier.wait(); + backend.mutate_workspace_tag(delete_request("delete", &tag)) + }) + }; + barrier.wait(); + let attached = attach.join().unwrap(); + assert!(delete.join().unwrap().unwrap().deleted); + assert!(backend.show(id.into()).unwrap().tags.is_empty()); + assert!( + backend + .list_workspace_tags(TagListRequest::default()) + .unwrap() + .is_empty() + ); + assert_eq!(audit_count(&backend), if attached.is_ok() { 3 } else { 2 }); + let links: i64 = backend + .open_connection() + .unwrap() + .query_row("SELECT COUNT(*) FROM typed_ticket_tags", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(links, 0); +} + +#[test] +fn ticket_tag_receipts_bind_selector_and_operation_kind_and_replay_before_assignment_fence() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let tag = create_tag(&backend, "create", "Tag"); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let request = set_request("attach", &[], std::slice::from_ref(&tag)); + let mut reused = request.clone(); + reused.operation_key = "create".into(); + assert!(matches!( + backend.set_ticket_tags(id.clone().into(), reused), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + let conn = backend.open_connection().unwrap(); + conn.execute_batch("CREATE TABLE ticket_active_worker_assignments (workspace_id TEXT,ticket_id TEXT,runtime_id TEXT,worker_id TEXT,assignment_id TEXT);").unwrap(); + conn.execute("INSERT INTO ticket_active_worker_assignments VALUES ('ws',?1,'runtime','worker','assignment')",params![id]).unwrap(); + let worker = backend.clone().with_event_attributes(BTreeMap::from([ + ("source_actor_role".into(), "ticket_worker".into()), + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ("source_assignment_id".into(), "assignment".into()), + ])); + let stored = worker + .set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone()) + .unwrap(); + conn.execute("DELETE FROM ticket_active_worker_assignments", []) + .unwrap(); + assert_eq!( + worker + .set_ticket_tags(TicketIdOrSlug::Id(id.clone()), request.clone()) + .unwrap(), + stored + ); + assert!(matches!( + worker.set_ticket_tags(TicketIdOrSlug::Query(id.clone()), request), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert!( + worker + .set_ticket_tags( + id.clone().into(), + set_request("new-operation", &stored, &[]) + ) + .is_err() + ); + assert_eq!(backend.show(id.clone().into()).unwrap().tags, stored); + backend + .mutate_workspace_tag(delete_request("delete", &tag)) + .unwrap(); + assert!( + backend + .set_ticket_tags( + id.into(), + set_request("deleted-attach", &[], std::slice::from_ref(&tag)) + ) + .is_err() + ); + assert_eq!(audit_count(&backend), 3); +} + +#[test] +fn tag_domain_dispatch_and_wire_contracts_are_exact() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let request = WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { + name: "Name".into(), + }, + }; + assert_eq!( + serde_json::to_value(&request).unwrap(), + serde_json::json!({"operation_key":"create","action":{"kind":"create","name":"Name"}}) + ); + let operation = TicketBackendOperation::MutateWorkspaceTag { request }; + let operation = serde_json::from_value(serde_json::to_value(operation).unwrap()).unwrap(); + let TicketBackendOperationResult::WorkspaceTag(tag) = + execute_ticket_backend_operation(&backend, operation).unwrap() + else { + panic!("tag result") + }; + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let result = execute_ticket_backend_operation( + &backend, + TicketBackendOperation::SetTicketTags { + id: id.clone().into(), + request: set_request("set", &[], std::slice::from_ref(&tag)), + }, + ) + .unwrap(); + assert!( + matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag.clone()]) + ); + let result = execute_ticket_backend_operation( + &backend, + TicketBackendOperation::ListWorkspaceTags { + request: TagListRequest::default(), + }, + ) + .unwrap(); + assert!(matches!(result,TicketBackendOperationResult::WorkspaceTags(tags) if tags==vec![tag])); + let ticket = backend.show(id.into()).unwrap(); + let mut wire = serde_json::to_value(&ticket).unwrap(); + wire.as_object_mut().unwrap().remove("tags"); + assert!( + serde_json::from_value::(wire) + .unwrap() + .tags + .is_empty() + ); + let summary = backend.list(TicketListQuery::all()).unwrap().remove(0); + let mut wire = serde_json::to_value(summary).unwrap(); + wire.as_object_mut().unwrap().remove("tags"); + assert!( + serde_json::from_value::(wire) + .unwrap() + .tags + .is_empty() + ); + let schema = schemars::schema_for!(WorkspaceTagAction); + assert!( + serde_json::to_string(&schema) + .unwrap() + .contains("expected_last_operation_key") + ); +} + +#[test] +fn unchanged_tag_set_records_receipt_without_claiming_a_membership_change() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let id = backend.create(NewTicket::new("ticket")).unwrap().id; + let before = backend.show(id.clone().into()).unwrap(); + let request = set_request("noop", &[], &[]); + assert!( + backend + .set_ticket_tags(id.clone().into(), request.clone()) + .unwrap() + .is_empty() + ); + assert!( + backend + .set_ticket_tags(id.clone().into(), request) + .unwrap() + .is_empty() + ); + assert_eq!( + backend.show(id.clone().into()).unwrap().events, + before.events + ); + assert_eq!(audit_count(&backend), 1); + for request in [ + TicketTagSetRequest { + expected_tag_ids: vec!["x".into(); 101], + tag_ids: vec![], + operation_key: "large-observation".into(), + }, + TicketTagSetRequest { + expected_tag_ids: vec![], + tag_ids: (0..101).map(|i| format!("tag-{i}")).collect(), + operation_key: "large-desired".into(), + }, + TicketTagSetRequest { + expected_tag_ids: vec![], + tag_ids: vec!["x".repeat(129)], + operation_key: "large-id".into(), + }, + ] { + assert!(backend.set_ticket_tags(id.clone().into(), request).is_err()); + assert_eq!( + backend.show(id.clone().into()).unwrap().events, + before.events + ); + assert_eq!(audit_count(&backend), 1); + } +} + +#[test] +fn tag_receipt_preserves_original_context_but_reassignment_does_not_change_request_identity() { + let dir = tempdir().unwrap(); + let backend = SqliteTicketBackend::open(dir.path().join("tags.db"), "ws").unwrap(); + let original_context = BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ("source_actor_role".into(), "ticket_worker".into()), + ("source_assignment_id".into(), "assignment-a".into()), + ]); + let original = backend + .clone() + .with_event_attributes(original_context.clone()); + let request = WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { + name: "ready".into(), + }, + }; + let tag = original.mutate_workspace_tag(request.clone()).unwrap(); + let reassigned = backend.clone().with_event_attributes(BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ("source_actor_role".into(), "contributor".into()), + ])); + assert_eq!(reassigned.mutate_workspace_tag(request).unwrap(), tag); + let stored: String = backend.with_read(|conn| conn.query_row("SELECT source_context_json FROM workspace_tag_operations WHERE workspace_id='ws' AND operation_key='create'", [], |row| row.get(0)).map_err(sqlite_err)).unwrap(); + assert_eq!( + serde_json::from_str::>(&stored).unwrap(), + original_context + ); + assert_eq!(audit_count(&backend), 1); +} diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 7b5a2f310..216f3c7cf 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -2,7 +2,11 @@ //! //! These tools are intentionally owned by the `ticket` crate so Worker features can //! install Ticket behavior without reimplementing domain/backend logic or -//! granting generic filesystem write authority. +//! granting generic filesystem write authority. Publication/read access is not mutation +//! authorization: the scoped Workspace backend authenticates and fences each operation. +//! Case judgment, classification, and self-reported handling are independent; none attests +//! MR approval or Worker execution. After stale or unknown outcomes, reread authority; +//! exact retries retain the full original input and key rather than silently rekeying. use std::sync::Arc; @@ -17,7 +21,7 @@ use crate::{ Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation, TicketRelationKind, - TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author, + TicketRelationView, TicketState, TicketStateChange, TicketSummary, default_author, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -33,7 +37,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024; const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100; const MAX_DIAGNOSTIC_LIMIT: usize = 500; -pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [ +pub const TICKET_BASE_TOOL_NAMES: [&str; 13] = [ "TicketCreate", "TicketEditItem", "QueryTicket", @@ -44,8 +48,7 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [ "TicketImplementationReport", "TicketMarkReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketDoctor", ]; @@ -68,7 +71,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = ["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; -pub const TICKET_TOOL_NAMES: [&str; 20] = [ +pub const TICKET_TOOL_NAMES: [&str; 19] = [ "TicketCreate", "TicketEditItem", "QueryTicket", @@ -80,8 +83,7 @@ pub const TICKET_TOOL_NAMES: [&str; 20] = [ "TicketMarkReady", "TicketIntakeReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketDoctor", "TicketRelationRecord", @@ -100,7 +102,7 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [ "TicketOrchestrationPlanQuery", ]; -pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [ +pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [ "TicketCreate", "TicketEditItem", "TicketComment", @@ -110,8 +112,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [ "TicketMarkReady", "TicketIntakeReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", @@ -133,18 +134,15 @@ const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Mark const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown."; const IMPLEMENTATION_REPORT_DESCRIPTION: &str = "Append a typed Ticket implementation_report event. `body` is Markdown."; -const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \ +const MARK_READY_DESCRIPTION: &str = "Mark an open Ticket ready for routing through the typed Ticket backend. \ Targets are optional; provided targets are validated against the Workspace catalog without Git resolution. \ -Records one typed state_changed event and transitions planning -> ready. `reason` is optional."; -const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \ +Records one typed state_changed event and leaves the case open. `reason` is optional."; +const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark an open Ticket ready for routing. \ The backend applies the same optional catalog targets validation as TicketMarkReady and commits the summary, \ -state_changed event, effective targets, and planning -> ready transition atomically."; +state_changed event, effective targets, and routing readiness update atomically."; const QUEUE_DESCRIPTION: &str = "Queue only the explicitly requested Ticket for Orchestrator routing through the typed \ -Ticket backend, regardless of state or dependencies. Dependency diagnostics are retained; no dependencies are auto-queued."; -const WORKFLOW_STATE_DESCRIPTION: &str = "Change Ticket state with a bounded audited state_changed event. \ -State and dependency metadata do not impose an implementation phase path. Reopening does not revive released work identities."; -const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \ -backend. The backend sets the Ticket state to closed and appends a close event."; +Ticket backend, when the case is open, regardless of dependencies. Dependency diagnostics are retained; no dependencies are auto-queued."; +const CASE_UPDATE_DESCRIPTION: &str = "Close, reopen or correct a Ticket closure judgment with typed reasons and content/state/observed-closure-key CAS. Exact input replay does not duplicate history. No Repository/MR prerequisite. Other reasons must be nonblank, NUL-free and at most 1024 UTF-8 bytes. Reopening never revives released work identities."; const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \ project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \ inverse views are derived, not stored."; @@ -177,8 +175,7 @@ fn base_tool_description(name: &str) -> &'static str { "TicketMarkReady" => MARK_READY_DESCRIPTION, "TicketIntakeReady" => INTAKE_READY_DESCRIPTION, "TicketQueue" => QUEUE_DESCRIPTION, - "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, - "TicketClose" => CLOSE_DESCRIPTION, + "TicketCaseUpdate" => CASE_UPDATE_DESCRIPTION, "TicketRelationRecord" => RELATION_RECORD_DESCRIPTION, "TicketRelationRemove" => RELATION_REMOVE_DESCRIPTION, "TicketRelationQuery" => RELATION_QUERY_DESCRIPTION, @@ -249,6 +246,28 @@ impl From for TicketToolBackend { } impl TicketBackend for TicketToolBackend { + fn list_workspace_tags( + &self, + request: crate::TagListRequest, + ) -> TicketResult> { + self.backend.list_workspace_tags(request) + } + + fn mutate_workspace_tag( + &self, + request: crate::WorkspaceTagMutation, + ) -> TicketResult { + self.backend.mutate_workspace_tag(request) + } + + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: crate::TicketTagSetRequest, + ) -> TicketResult> { + self.backend.set_ticket_tags(id, request) + } + fn default_intake_ready_state_change_body(&self, from: &str) -> String { self.backend.default_intake_ready_state_change_body(from) } @@ -301,33 +320,8 @@ impl TicketBackend for TicketToolBackend { self.backend.add_intake_summary(id, summary) } - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> TicketResult<()> { - self.backend.set_state_field(id, field, change) - } - - fn set_workflow_state( - &self, - id: TicketIdOrSlug, - change: TicketStateChange, - ) -> TicketResult<()> { - self.backend.set_workflow_state(id, change) - } - - fn update_state( - &self, - ticket: &str, - request: crate::TicketStateUpdate, - ) -> TicketResult { - self.backend.update_state(ticket, request) - } - - fn complete(&self, ticket: &str, request: crate::TicketCompletion) -> TicketResult { - self.backend.complete(ticket, request) + fn update_case(&self, ticket: &str, request: crate::TicketCaseUpdate) -> TicketResult { + self.backend.update_case(ticket, request) } fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult { @@ -342,10 +336,6 @@ impl TicketBackend for TicketToolBackend { self.backend.queue_ready(id, queued_by) } - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> { - self.backend.close(id, resolution) - } - fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -412,9 +402,9 @@ struct TicketCreateParams { /// Optional risk flag frontmatter values. #[serde(default)] risk_flags: Vec, - /// Optional state frontmatter value. Defaults to `planning`. + /// Optional state frontmatter value. Defaults to `open`. #[serde(default)] - state: Option, + state: Option, /// Optional queued_at frontmatter value. #[serde(default)] queued_at: Option, @@ -449,34 +439,21 @@ struct TicketEditItemParams { #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] -enum TicketWorkflowStateParam { - Planning, - Ready, - Queued, - Inprogress, - Done, +enum TicketStateParam { + Open, Closed, } -impl TicketWorkflowStateParam { - fn into_state(self) -> TicketWorkflowState { +impl TicketStateParam { + fn into_state(self) -> TicketState { match self { - Self::Planning => TicketWorkflowState::Planning, - Self::Ready => TicketWorkflowState::Ready, - Self::Queued => TicketWorkflowState::Queued, - Self::Inprogress => TicketWorkflowState::InProgress, - Self::Done => TicketWorkflowState::Done, - Self::Closed => TicketWorkflowState::Closed, + Self::Open => TicketState::Open, + Self::Closed => TicketState::Closed, } } - fn into_list_state(self) -> TicketListState { match self { - Self::Planning => TicketListState::Planning, - Self::Ready => TicketListState::Ready, - Self::Queued => TicketListState::Queued, - Self::Inprogress => TicketListState::InProgress, - Self::Done => TicketListState::Done, + Self::Open => TicketListState::Open, Self::Closed => TicketListState::Closed, } } @@ -486,11 +463,7 @@ impl TicketWorkflowStateParam { #[serde(rename_all = "snake_case")] enum QueryTicketStateParam { Active, - Planning, - Ready, - Queued, - Inprogress, - Done, + Open, Closed, All, } @@ -498,11 +471,7 @@ enum QueryTicketStateParam { impl QueryTicketStateParam { fn as_list_state(self) -> Option { match self { - Self::Planning => Some(TicketListState::Planning), - Self::Ready => Some(TicketListState::Ready), - Self::Queued => Some(TicketListState::Queued), - Self::Inprogress => Some(TicketListState::InProgress), - Self::Done => Some(TicketListState::Done), + Self::Open => Some(TicketListState::Open), Self::Closed => Some(TicketListState::Closed), Self::Active | Self::All => None, } @@ -511,12 +480,12 @@ impl QueryTicketStateParam { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct QueryTicketParams { - /// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets. + /// State filter. Defaults to active Tickets (open cases). Use `all` to include closed Tickets. #[serde(default)] state: Option, /// Explicit workflow-state filter list. Cannot be combined with `state`. #[serde(default)] - states: Option>, + states: Option>, /// Maximum number of summaries to return. Defaults to 50, max 100. #[serde(default)] limit: Option, @@ -606,25 +575,21 @@ struct TicketQueueParams { } #[derive(Debug, Deserialize, schemars::JsonSchema)] -struct TicketWorkflowStateParams { - /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. +#[serde(deny_unknown_fields)] +struct TicketCaseUpdateParams { + /// Ticket reference. Prefer T-*. ticket: String, - /// Expected current state. The backend rejects stale transitions. - from: TicketWorkflowStateParam, - /// Target state. - to: TicketWorkflowStateParam, - /// Reason attached to the typed state_changed event. - reason: String, - /// Markdown body for the typed state_changed event. - body: String, -} - -#[derive(Debug, Deserialize, schemars::JsonSchema)] -struct TicketCloseParams { - /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. - ticket: String, - /// Markdown resolution written to resolution.md and thread.md. - resolution: String, + expected_content_digest: String, + expected_state: TicketState, + /// Explicit null means no closure has been observed. Omission is rejected. + #[serde(deserialize_with = "crate::deserialize_case_closure_observation")] + #[schemars(required)] + expected_closure_operation_key: Option, + operation_key: String, + action: crate::TicketCaseAction, + explanation: String, + #[serde(default)] + references: Vec, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -879,12 +844,7 @@ struct TicketQueueTool { } #[derive(Clone)] -struct TicketWorkflowStateTool { - backend: TicketToolBackend, -} - -#[derive(Clone)] -struct TicketCloseTool { +struct TicketCaseUpdateTool { backend: TicketToolBackend, } @@ -933,7 +893,7 @@ impl Tool for TicketCreateTool { let params: TicketCreateParams = parse_input("TicketCreate", input_json)?; if params .state - .is_some_and(|state| !matches!(state.into_state(), TicketWorkflowState::Planning)) + .is_some_and(|state| !matches!(state.into_state(), TicketState::Open)) { return Err(backend_error( "TicketCreate", @@ -941,7 +901,7 @@ impl Tool for TicketCreateTool { from: "creation".to_owned(), to: params .state - .expect("checked non-planning state") + .expect("checked non-open state") .into_state() .as_str() .to_owned(), @@ -956,7 +916,7 @@ impl Tool for TicketCreateTool { input.assignee = params.assignee; input.readiness = params.readiness; input.risk_flags = params.risk_flags; - input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state); + input.workflow_state = params.state.map(TicketStateParam::into_state); input.queued_by = None; input.queued_at = params.queued_at; input.targets = params.targets; @@ -969,7 +929,7 @@ impl Tool for TicketCreateTool { format!("Created ticket {}", created.id), json!(TicketRefOutput { id: created.id, - state: "planning".to_string(), + state: "open".to_string(), }), )) } @@ -1183,7 +1143,7 @@ impl Tool for TicketMarkReadyTool { ) .map_err(|error| backend_error("TicketMarkReady", error))?; Ok(json_output( - format!("Marked ticket {} state ready", params.ticket), + format!("Marked ticket {} routing readiness ready", params.ticket), json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), @@ -1215,7 +1175,10 @@ impl Tool for TicketIntakeReadyTool { ) .map_err(|error| backend_error("TicketIntakeReady", error))?; Ok(json_output( - format!("Marked ticket {} state ready after intake", params.ticket), + format!( + "Marked ticket {} routing readiness ready after intake", + params.ticket + ), json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), @@ -1263,68 +1226,42 @@ impl Tool for TicketQueueTool { } #[async_trait] -impl Tool for TicketWorkflowStateTool { +impl Tool for TicketCaseUpdateTool { async fn execute( &self, input_json: &str, _ctx: agen::tool::ToolExecutionContext, ) -> Result { - let params: TicketWorkflowStateParams = parse_input("TicketWorkflowState", input_json)?; - let from = params.from.into_state(); - let to = params.to.into_state(); - if from == to { - return Err(ToolError::InvalidArgument( - "state transition must change state".to_string(), - )); - } - let mut change = - TicketStateChange::new(from.as_str(), to.as_str(), params.reason, params.body); - change.author = None; - self.backend - .set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change) - .map_err(|error| backend_error("TicketWorkflowState", error))?; - let ticket_ref = - model_ticket_reference(&self.backend, ¶ms.ticket, "TicketWorkflowState")?; + let params: TicketCaseUpdateParams = parse_input("TicketCaseUpdate", input_json)?; + let record = self + .backend + .update_case( + ¶ms.ticket, + crate::TicketCaseUpdate { + expected_content_digest: params.expected_content_digest, + expected_state: params.expected_state, + expected_closure_operation_key: params.expected_closure_operation_key, + operation_key: params.operation_key, + action: params.action, + explanation: params.explanation, + references: params.references, + author: None, + }, + ) + .map_err(|error| backend_error("TicketCaseUpdate", error))?; + let reference = model_ticket_reference(&self.backend, ¶ms.ticket, "TicketCaseUpdate")?; Ok(json_output( format!( - "Transitioned ticket {} state {} -> {}", - ticket_ref, - from.as_str(), - to.as_str() + "Updated Ticket {} case to {}", + reference, record.meta.workflow_state ), json!({ - "ticket": ticket_ref, - "from": from.as_str(), - "to": to.as_str(), - "state": to.as_str(), - "ok": true + "ticket": reference, "state": record.meta.workflow_state, "closure_operation_key": record.closure_operation_key, "current_closure": record.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "author": j.author, "explanation": truncate_text(&j.explanation, DEFAULT_BODY_MAX_BYTES)})), "ok": true }), )) } } -#[async_trait] -impl Tool for TicketCloseTool { - async fn execute( - &self, - input_json: &str, - _ctx: agen::tool::ToolExecutionContext, - ) -> Result { - let params: TicketCloseParams = parse_input("TicketClose", input_json)?; - self.backend - .close( - TicketIdOrSlug::Query(params.ticket.clone()), - MarkdownText::new(params.resolution), - ) - .map_err(|error| backend_error("TicketClose", error))?; - let ticket_ref = model_ticket_reference(&self.backend, ¶ms.ticket, "TicketClose")?; - Ok(json_output( - format!("Closed ticket {ticket_ref}"), - json!({ "ticket": ticket_ref, "state": "closed", "ok": true }), - )) - } -} - #[async_trait] impl Tool for TicketRelationRecordTool { async fn execute( @@ -1740,6 +1677,11 @@ fn ticket_json( "queued_at": ticket.meta.queued_at, "targets": ticket.meta.targets, }, + "content_digest": crate::ticket_content_digest(ticket), + "closure_operation_key": ticket.closure_operation_key, + "current_closure": ticket.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})), + "case_history": { "count": ticket.case_history.len(), "items": ticket.case_history.iter().rev().take(event_limit).map(|j| json!({"operation_key": j.operation_key, "previous_closure_operation_key": j.previous_closure_operation_key, "action": j.action, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})).collect::>() }, + "legacy_workflow_snapshot": ticket.legacy_workflow_snapshot, "body": truncate_text(ticket.document.body.as_str(), body_max_bytes), "events": { "count": event_count, @@ -1850,10 +1792,7 @@ fn input_schema(name: &str) -> Value { "TicketMarkReady" => serde_json::to_value(schemars::schema_for!(TicketMarkReadyParams)), "TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)), "TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)), - "TicketWorkflowState" => { - serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams)) - } - "TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)), + "TicketCaseUpdate" => serde_json::to_value(schemars::schema_for!(TicketCaseUpdateParams)), "TicketDependencyCheck" => { serde_json::to_value(schemars::schema_for!(TicketDependencyCheckParams)) } @@ -1899,8 +1838,7 @@ impl_from_backend!(TicketImplementationReportTool); impl_from_backend!(TicketMarkReadyTool); impl_from_backend!(TicketIntakeReadyTool); impl_from_backend!(TicketQueueTool); -impl_from_backend!(TicketWorkflowStateTool); -impl_from_backend!(TicketCloseTool); +impl_from_backend!(TicketCaseUpdateTool); impl_from_backend!(TicketRelationRecordTool); impl_from_backend!(TicketRelationRemoveTool); impl_from_backend!(TicketRelationQueryTool); @@ -1927,8 +1865,7 @@ pub fn ticket_tools(backend: impl Into) -> Vec("TicketMarkReady", backend.clone()), tool_definition::("TicketIntakeReady", backend.clone()), tool_definition::("TicketQueue", backend.clone()), - tool_definition::("TicketWorkflowState", backend.clone()), - tool_definition::("TicketClose", backend.clone()), + tool_definition::("TicketCaseUpdate", backend.clone()), tool_definition::("TicketDependencyCheck", backend.clone()), tool_definition::("TicketDoctor", backend.clone()), tool_definition::("TicketRelationRecord", backend.clone()), @@ -2015,7 +1952,7 @@ mod tests { fn post_mutation_reference_failures_are_outcome_unknown() { let temp = TempDir::new().unwrap(); let backend = TicketToolBackend::new(backend(&temp)); - let error = model_ticket_reference(&backend, "missing", "TicketClose").unwrap_err(); + let error = model_ticket_reference(&backend, "missing", "TicketCaseUpdate").unwrap_err(); assert!(matches!( error, ToolError::Internal(message) @@ -2048,8 +1985,7 @@ mod tests { "TicketMarkReady", "TicketIntakeReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord" @@ -2069,24 +2005,6 @@ mod tests { ); } - #[test] - fn state_tool_description_explains_phase_independence() { - let temp = TempDir::new().unwrap(); - let definition = ticket_tools(backend(&temp)) - .into_iter() - .find(|definition| definition().0.name == "TicketWorkflowState") - .expect("state tool exists"); - let (meta, _) = definition(); - assert!( - meta.description - .contains("do not impose an implementation phase path") - ); - assert!( - meta.description - .contains("does not revive released work identities") - ); - } - #[test] fn tool_descriptions_include_configured_ticket_record_language_guidance() { let temp = TempDir::new().unwrap(); @@ -2151,10 +2069,7 @@ mod tests { assert!(!created_text.contains("attention_required")); let listed = list - .execute( - &json!({ "state": "planning" }).to_string(), - Default::default(), - ) + .execute(&json!({ "state": "open" }).to_string(), Default::default()) .await .unwrap(); assert!(listed.summary.contains("Listed 1 ticket")); @@ -2276,7 +2191,7 @@ mod tests { let list = tool_by_name(backend.clone(), "QueryTicket"); for index in 0..(DEFAULT_LIST_LIMIT + 3) { let mut ticket = NewTicket::new(format!("Closed Ticket {index:03}")); - ticket.workflow_state = Some(TicketWorkflowState::Closed); + ticket.workflow_state = Some(TicketState::Closed); backend.create(ticket).unwrap(); } for index in 0..3 { @@ -2334,22 +2249,22 @@ mod tests { let list = tool_by_name(backend.clone(), "QueryTicket"); let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap(); let mut ready_input = NewTicket::new("Ready Ticket"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); + ready_input.workflow_state = Some(TicketState::Open); let ready = backend.create(ready_input).unwrap(); let mut closed_input = NewTicket::new("Closed Ticket"); - closed_input.workflow_state = Some(TicketWorkflowState::Closed); + closed_input.workflow_state = Some(TicketState::Closed); let closed = backend.create(closed_input).unwrap(); let listed = list .execute( - &json!({ "states": ["planning", "closed"] }).to_string(), + &json!({ "states": ["open", "closed"] }).to_string(), Default::default(), ) .await .unwrap(); let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap(); - assert_eq!(listed_json["state_filter"], "planning,closed"); - assert_eq!(listed_json["count"].as_u64(), Some(2)); + assert_eq!(listed_json["state_filter"], "open,closed"); + assert_eq!(listed_json["count"].as_u64(), Some(3)); let listed_ids = listed_json["tickets"] .as_array() .unwrap() @@ -2358,11 +2273,11 @@ mod tests { .collect::>(); assert!(listed_ids.contains(&planning.id.as_str())); assert!(listed_ids.contains(&closed.id.as_str())); - assert!(!listed_ids.contains(&ready.id.as_str())); + assert!(listed_ids.contains(&ready.id.as_str())); let mixed = list .execute( - &json!({ "state": "active", "states": ["planning"] }).to_string(), + &json!({ "state": "active", "states": ["open"] }).to_string(), Default::default(), ) .await; @@ -2374,62 +2289,6 @@ mod tests { assert!(empty.is_err()); } - #[tokio::test] - async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let list = tool_by_name(backend.clone(), "QueryTicket"); - let close = tool_by_name(backend.clone(), "TicketClose"); - let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST"; - let thread_secret = "THREAD_SECRET_DO_NOT_LIST"; - let resolution_secret = "RESOLUTION_SECRET_DO_NOT_LIST"; - let mut ticket = NewTicket::new("Leak Probe"); - ticket.body = MarkdownText::new(format!("Item body {body_secret}")); - ticket.workflow_state = Some(TicketWorkflowState::Done); - let created = backend.create(ticket).unwrap(); - backend - .add_event( - TicketIdOrSlug::Id(created.id.clone()), - NewTicketEvent::new(TicketEventKind::Comment, format!("Thread {thread_secret}")), - ) - .unwrap(); - close - .execute( - &json!({ - "ticket": created.id, - "resolution": format!("Resolution {resolution_secret}") - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - - let listed = list - .execute( - &json!({ "state": "closed" }).to_string(), - Default::default(), - ) - .await - .unwrap(); - let listed_content = listed.content.unwrap(); - for secret in [body_secret, thread_secret, resolution_secret] { - assert!(!listed_content.contains(secret)); - } - let listed_json: Value = serde_json::from_str(&listed_content).unwrap(); - let ticket = listed_json["tickets"][0].as_object().unwrap(); - for forbidden_key in [ - "body", - "document", - "events", - "thread", - "artifacts", - "resolution", - ] { - assert!(!ticket.contains_key(forbidden_key)); - } - } - #[tokio::test] async fn ticket_edit_item_tool_supports_exact_body_replacement() { let temp = TempDir::new().unwrap(); @@ -2582,234 +2441,6 @@ mod tests { assert_eq!(shown_after_remove_json["relations"]["incoming"], json!([])); } - #[tokio::test] - async fn ticket_tools_report_state_and_close_are_doctor_clean() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let created = backend.create(NewTicket::new("Flow Tool")).unwrap(); - let report = tool_by_name(backend.clone(), "TicketImplementationReport"); - let close = tool_by_name(backend.clone(), "TicketClose"); - let doctor = tool_by_name(backend.clone(), "TicketDoctor"); - - report - .execute( - &json!({ - "ticket": created.id.clone(), - "body": "Implemented." - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - close - .execute( - &json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - - let report = doctor - .execute(&json!({}).to_string(), Default::default()) - .await - .unwrap(); - assert!(report.summary.contains("0 error(s)")); - let closed = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert!(closed.resolution.is_some()); - assert!( - closed - .events - .iter() - .any(|event| event.kind == TicketEventKind::ImplementationReport) - ); - assert!( - closed - .events - .iter() - .any(|event| event.kind == TicketEventKind::Close) - ); - } - - #[tokio::test] - async fn queue_workflow_and_close_project_internal_inputs_to_ticket_keys() { - let temp = TempDir::new().unwrap(); - let inner = sqlite_backend(&temp); - let mut dependency_input = NewTicket::new("Dependency"); - dependency_input.targets = vec![write_target("main")]; - let dependency = inner.create(dependency_input).unwrap(); - let mut target_input = NewTicket::new("Target"); - target_input.targets = vec![write_target("main")]; - let target = inner.create(target_input).unwrap(); - inner - .add_ticket_relation( - TicketIdOrSlug::Id(target.id.clone()), - NewTicketRelation { - kind: TicketRelationKind::DependsOn, - target: dependency.id.clone(), - note: None, - author: None, - }, - ) - .unwrap(); - for id in [&dependency.id, &target.id] { - inner - .mark_ready( - TicketIdOrSlug::Id(id.clone()), - TicketMarkReady { - operation_key: format!("ready-{id}"), - reason: None, - author: None, - intake_summary: None, - }, - ) - .unwrap(); - } - let target_key = target.resource_key.clone().unwrap(); - let dependency_key = dependency.resource_key.clone().unwrap(); - let backend = inner; - let queue = tool_by_name(TicketToolBackend::new(backend.clone()), "TicketQueue"); - let workflow = tool_by_name( - TicketToolBackend::new(backend.clone()), - "TicketWorkflowState", - ); - let close = tool_by_name(TicketToolBackend::new(backend), "TicketClose"); - - let queued = queue - .execute( - &json!({"ticket": target.id.clone()}).to_string(), - Default::default(), - ) - .await - .unwrap(); - assert!(queued.summary.contains("1 ticket(s)")); - let queued_content = queued.content.unwrap(); - assert!(queued_content.contains(&target_key)); - assert!(!queued_content.contains(&dependency_key)); - assert!(!queued_content.contains(&target.id)); - assert!(!queued_content.contains(&dependency.id)); - - for (from, to) in [("queued", "inprogress"), ("inprogress", "done")] { - let transitioned = workflow - .execute( - &json!({ - "ticket": target.id.clone(), - "from": from, - "to": to, - "reason": "test_transition", - "body": "transitioned", - "author": "tester" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - assert!(transitioned.summary.contains(&target_key)); - assert!(!transitioned.summary.contains(&target.id)); - let content = transitioned.content.unwrap(); - assert!(content.contains(&target_key)); - assert!(!content.contains(&target.id)); - } - - let closed = close - .execute( - &json!({"ticket": target.id.clone(), "resolution": "Done"}).to_string(), - Default::default(), - ) - .await - .unwrap(); - assert!(closed.summary.contains(&target_key)); - assert!(!closed.summary.contains(&target.id)); - let content = closed.content.unwrap(); - assert!(content.contains(&target_key)); - assert!(!content.contains(&target.id)); - } - - #[tokio::test] - async fn ticket_workflow_tools_mark_ready_and_transition_state() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let mut input = NewTicket::new("Workflow Tool"); - input.targets = vec![write_target("main")]; - let created = backend.create(input).unwrap(); - let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady"); - let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); - - intake_ready - .execute( - &json!({ - "ticket": created.id.clone(), - "reason": "requirements accepted" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - backend - .queue_ready(TicketIdOrSlug::Id(created.id.clone()), "panel") - .unwrap(); - workflow - .execute( - &json!({ - "ticket": created.id.clone(), - "from": "queued", - "to": "inprogress", - "reason": "orchestrator_started", - "body": "Orchestrator started implementation.\n", - "author": "orchestrator" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - workflow - .execute( - &json!({ - "ticket": created.id.clone(), - "from": "inprogress", - "to": "done", - "reason": "implementation_complete", - "body": "Implementation finished and is ready for close.\n", - "author": "orchestrator" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - - let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done); - assert!(record.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("planning") - && event.to.as_deref() == Some("ready") - && event.attributes.contains_key("request_fingerprint") - })); - let transitions = record - .events - .iter() - .filter(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - }) - .map(|event| (event.from.as_deref(), event.to.as_deref())) - .collect::>(); - assert_eq!( - transitions, - vec![ - (Some("planning"), Some("ready")), - (Some("ready"), Some("queued")), - (Some("queued"), Some("inprogress")), - (Some("inprogress"), Some("done")) - ] - ); - } - #[tokio::test] async fn ticket_intake_ready_records_summary_with_validated_target() { let temp = TempDir::new().unwrap(); @@ -2830,7 +2461,7 @@ mod tests { .await .unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(record.meta.workflow_state, TicketState::Open); assert_eq!( record.meta.targets[0].ref_selector.as_deref(), Some("develop") @@ -2846,132 +2477,11 @@ mod tests { } #[tokio::test] - async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() { + async fn ticket_mark_ready_tool_rejects_closed_ticket() { let temp = TempDir::new().unwrap(); let backend = backend(&temp); - let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); - - let mut ready_input = NewTicket::new("Ready Needs Planning"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); - let ready = backend.create(ready_input).unwrap(); - workflow - .execute( - &json!({ - "ticket": ready.id, - "from": "ready", - "to": "planning", - "reason": "missing_acceptance_decision", - "body": "Missing decision: clarify acceptance criteria before queueing.\n", - "author": "orchestrator" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - let ready_record = backend.show(TicketIdOrSlug::Id(ready.id)).unwrap(); - assert_eq!( - ready_record.meta.workflow_state, - TicketWorkflowState::Planning - ); - assert!(ready_record.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("ready") - && event.to.as_deref() == Some("planning") - && event.reason.as_deref() == Some("missing_acceptance_decision") - })); - - let mut queued_input = NewTicket::new("Queued Needs Planning"); - queued_input.workflow_state = Some(TicketWorkflowState::Queued); - let queued = backend.create(queued_input).unwrap(); - workflow - .execute( - &json!({ - "ticket": queued.id, - "from": "queued", - "to": "planning", - "reason": "missing_authority_decision", - "body": "Missing decision: define authority boundary before implementation side effects.\n", - "author": "orchestrator" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - let queued_record = backend.show(TicketIdOrSlug::Id(queued.id)).unwrap(); - assert_eq!( - queued_record.meta.workflow_state, - TicketWorkflowState::Planning - ); - assert!(queued_record.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("queued") - && event.to.as_deref() == Some("planning") - && event.reason.as_deref() == Some("missing_authority_decision") - })); - } - - #[tokio::test] - async fn ticket_workflow_tool_rejects_stale_transition_without_state_move() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let created = backend - .create(NewTicket::new("Stale Workflow Tool")) - .unwrap(); - let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); - - let error = workflow - .execute( - &json!({ - "ticket": created.id.clone(), - "from": "queued", - "to": "inprogress", - "reason": "orchestrator_started", - "body": "Should not apply.\n" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap_err(); - - assert!(matches!(error, ToolError::ExecutionFailed(_))); - let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning); - assert!(!record.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - })); - } - - #[tokio::test] - async fn ticket_workflow_tool_allows_direct_state_changes_without_phase_path() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); - for (from, to) in [ - (TicketWorkflowState::Ready, TicketWorkflowState::InProgress), - (TicketWorkflowState::Done, TicketWorkflowState::Planning), - (TicketWorkflowState::Queued, TicketWorkflowState::Done), - ] { - let mut input = NewTicket::new(format!("{from} to {to}")); - input.workflow_state = Some(from); - let ticket = backend.create(input).unwrap(); - workflow.execute(&json!({"ticket": ticket.id, "from": from.as_str(), "to": to.as_str(), "reason": "explicit_update", "body": "State metadata updated."}).to_string(), Default::default()).await.unwrap(); - assert_eq!( - backend.show(ticket.id.into()).unwrap().meta.workflow_state, - to - ); - } - } - - #[tokio::test] - async fn ticket_mark_ready_tool_rejects_non_planning_ticket() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let mut input = NewTicket::new("Already Ready"); - input.workflow_state = Some(TicketWorkflowState::Ready); + let mut input = NewTicket::new("Already Closed"); + input.workflow_state = Some(TicketState::Closed); let created = backend.create(input).unwrap(); let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady"); @@ -2979,7 +2489,7 @@ mod tests { .execute( &json!({ "ticket": created.id.clone(), - "intake_summary": "Should not rewrite ready ticket." + "intake_summary": "Should not rewrite closed case." }) .to_string(), Default::default(), @@ -2989,7 +2499,7 @@ mod tests { assert!(error.to_string().contains("stale ticket workflow state")); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(record.meta.workflow_state, TicketState::Closed); assert!(!record.events.iter().any(|event| { event.kind == TicketEventKind::StateChanged && event.state_field.as_deref() == Some("state") @@ -3042,7 +2552,7 @@ mod tests { assert_eq!(found_json["records"][0]["related_ticket"], second.id); let current = backend.show(TicketIdOrSlug::Id(first.id)).unwrap(); - assert_eq!(current.meta.workflow_state, TicketWorkflowState::Planning); + assert_eq!(current.meta.workflow_state, TicketState::Open); } #[tokio::test] @@ -3193,4 +2703,147 @@ mod tests { )); let _ = create; } + #[test] + fn case_tool_schema_exposes_common_cas_and_no_legacy_unguarded_mutation() { + let temp = TempDir::new().unwrap(); + let definition = ticket_tools(backend(&temp)) + .into_iter() + .find(|d| d().0.name == "TicketCaseUpdate") + .unwrap(); + let (meta, _) = definition(); + let schema = &meta.input_schema; + for field in [ + "ticket", + "expected_content_digest", + "expected_state", + "expected_closure_operation_key", + "operation_key", + "action", + "explanation", + ] { + assert!( + schema["required"] + .as_array() + .unwrap() + .iter() + .any(|name| name == field), + "missing {field}: {schema}" + ); + } + assert!( + schema["properties"] + .get("expected_closure_operation_key") + .is_some() + ); + assert!(!TICKET_TOOL_NAMES.contains(&"TicketWorkflowState")); + assert!(!TICKET_TOOL_NAMES.contains(&"TicketClose")); + assert!(meta.description.contains("1024 UTF-8 bytes")); + } + + #[tokio::test] + async fn case_tool_closes_replays_corrects_and_reopens_with_bounded_show_history() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let created = backend.create(NewTicket::new("Case via tool")).unwrap(); + let observed = backend.show(created.id.clone().into()).unwrap(); + let update = tool_by_name(backend.clone(), "TicketCaseUpdate"); + let input = json!({"ticket": created.id, "expected_content_digest": crate::ticket_content_digest(&observed), "expected_state": "open", "expected_closure_operation_key": null, + "operation_key": "tool-close", "action": {"action":"close", "reason":"not_planned"}, "explanation": "Not pursuing this case.\n\nThis is not a failure milestone.", "references": [{"kind":"result", "target":"doc:decision"}]}); + update + .execute(&input.to_string(), Default::default()) + .await + .unwrap(); + update + .execute(&input.to_string(), Default::default()) + .await + .unwrap(); + let closed = backend.show(created.id.clone().into()).unwrap(); + assert_eq!(closed.case_history.len(), 1); + assert_eq!( + closed.current_closure.as_ref().unwrap().reason, + crate::TicketClosureReason::NotPlanned + ); + let mut correction = input.clone(); + correction["expected_state"] = json!("closed"); + correction["expected_closure_operation_key"] = json!("tool-close"); + correction["operation_key"] = json!("tool-correct"); + correction["action"] = json!({"action":"correct_closure", "reason":"duplicate"}); + correction["explanation"] = json!("A long judgment ".repeat(1000)); + update + .execute(&correction.to_string(), Default::default()) + .await + .unwrap(); + let show = tool_by_name(backend.clone(), "ShowTicket"); + let output = show + .execute( + &json!({"id":created.id, "event_limit":1, "body_max_bytes":128}).to_string(), + Default::default(), + ) + .await + .unwrap(); + let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(value["closure_operation_key"], "tool-correct"); + assert_eq!(value["case_history"]["count"], 2); + assert_eq!(value["case_history"]["items"].as_array().unwrap().len(), 1); + assert!( + value["current_closure"]["explanation"] + .as_str() + .unwrap() + .len() + < 256 + ); + assert!(value["content_digest"].as_str().unwrap().len() == 64); + let mut reopen = correction; + reopen["expected_closure_operation_key"] = json!("tool-correct"); + reopen["operation_key"] = json!("tool-reopen"); + reopen["action"] = json!({"action":"reopen"}); + reopen["explanation"] = json!("Reconsider"); + update + .execute(&reopen.to_string(), Default::default()) + .await + .unwrap(); + let reopened = backend.show(created.id.clone().into()).unwrap(); + assert_eq!(reopened.meta.workflow_state, TicketState::Open); + assert_eq!(reopened.case_history.len(), 3); + assert_eq!( + reopened.closure_operation_key.as_deref(), + Some("tool-correct") + ); + assert!( + update + .execute( + &reopen.to_string().replace("tool-correct", "wrong-closure"), + Default::default() + ) + .await + .is_err() + ); + } + + #[tokio::test] + async fn case_tool_rejects_legacy_states_and_unrecognized_input_fields() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let created = backend.create(NewTicket::new("Validation")).unwrap(); + let observed = backend.show(created.id.clone().into()).unwrap(); + let update = tool_by_name(backend.clone(), "TicketCaseUpdate"); + let input = json!({"ticket":created.id, "expected_content_digest":crate::ticket_content_digest(&observed), "expected_state":"open", "operation_key":"key", "action":{"action":"close", "reason":"unknown"}, "explanation":"Reason"}); + let mut legacy = input.clone(); + legacy["expected_state"] = json!("done"); + assert!( + update + .execute(&legacy.to_string(), Default::default()) + .await + .is_err() + ); + let mut unknown = input; + unknown["from"] = json!("open"); + assert!( + update + .execute(&unknown.to_string(), Default::default()) + .await + .is_err() + ); + assert_eq!(backend.show(created.id.into()).unwrap(), observed); + } } diff --git a/crates/ticket/tests/case.rs b/crates/ticket/tests/case.rs new file mode 100644 index 000000000..c4dd8c321 --- /dev/null +++ b/crates/ticket/tests/case.rs @@ -0,0 +1,800 @@ +//! Public Ticket case authority contracts over isolated SQLite stores. +use rusqlite::{Connection, params}; +use std::collections::BTreeMap; +use std::sync::{Arc, Barrier}; +use tempfile::TempDir; +use ticket::*; + +fn backend(temp: &TempDir) -> SqliteTicketBackend { + SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap() +} +fn request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate { + TicketCaseUpdate { + expected_content_digest: ticket_content_digest(ticket), + expected_state: ticket.meta.workflow_state, + expected_closure_operation_key: ticket.closure_operation_key.clone(), + operation_key: key.into(), + action, + explanation: "A reasoned judgment.\n\nNot an execution milestone.".into(), + references: vec![TicketReference { + kind: "result".into(), + target: "document:example".into(), + }], + author: Some("human:alice".into()), + } +} +fn close(reason: TicketClosureReason) -> TicketCaseAction { + TicketCaseAction::Close { reason } +} +fn create(backend: &SqliteTicketBackend) -> Ticket { + let mut input = NewTicket::new("Independent case"); + input.assignee = Some("historical-assignee".into()); + backend.create_with_snapshot(input).unwrap().1 +} + +#[test] +fn close_reopen_and_correction_preserve_reason_actor_references_and_identity() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::NotPlanned)), + ) + .unwrap(); + assert!(closed.meta.targets.is_empty()); + assert_eq!(closed.meta.workflow_state, TicketState::Closed); + let first_judgment = closed.current_closure.clone().unwrap(); + assert_eq!(first_judgment.reason, TicketClosureReason::NotPlanned); + assert_eq!(first_judgment.author, "human:alice"); + assert_eq!( + first_judgment.references, + request(&initial, "close", close(TicketClosureReason::NotPlanned)).references + ); + let corrected = store + .update_case( + &initial.meta.id, + request( + &closed, + "correct", + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Duplicate, + }, + ), + ) + .unwrap(); + assert_eq!( + corrected.current_closure.as_ref().unwrap().reason, + TicketClosureReason::Duplicate + ); + assert_eq!(corrected.case_history[0], closed.case_history[0]); + assert_eq!( + corrected.case_history[1] + .previous_closure_operation_key + .as_deref(), + Some("close") + ); + let reopened = store + .update_case( + &initial.meta.id, + request(&corrected, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + assert_eq!(reopened.meta.workflow_state, TicketState::Open); + assert!(reopened.current_closure.is_none()); + assert!(reopened.resolution.is_none()); + assert_eq!(reopened.closure_operation_key.as_deref(), Some("correct")); + assert_eq!(reopened.case_history.len(), 3); + assert_eq!(reopened.meta.assignee, initial.meta.assignee); + assert_eq!(reopened.meta.resource_key, initial.meta.resource_key); + assert_eq!( + &reopened.events[..initial.events.len()], + initial.events.as_slice() + ); + assert_eq!( + store.show(initial.meta.id.clone().into()).unwrap(), + reopened + ); +} + +#[test] +fn closure_reasons_round_trip_without_repository_or_merge_request_prerequisites() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + for reason in [ + TicketClosureReason::Completed, + TicketClosureReason::NotPlanned, + TicketClosureReason::Duplicate, + TicketClosureReason::Unknown, + TicketClosureReason::Other("Custom decision".into()), + ] { + let initial = create(&store); + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(reason.clone())), + ) + .unwrap(); + assert_eq!(closed.current_closure.unwrap().reason, reason); + } +} + +#[test] +fn case_cas_rejects_stale_content_state_and_closure_key_without_writes() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let base = request(&initial, "close", close(TicketClosureReason::Completed)); + let mut stale = base.clone(); + stale.expected_content_digest = "stale".into(); + assert!(matches!( + store.update_case(&initial.meta.id, stale), + Err(TicketError::StaleContent { .. }) + )); + let mut stale = base.clone(); + stale.expected_state = TicketState::Closed; + assert!(matches!( + store.update_case(&initial.meta.id, stale), + Err(TicketError::StaleWorkflowState { .. }) + )); + let mut stale = base; + stale.expected_closure_operation_key = Some("unobserved".into()); + assert!(matches!( + store.update_case(&initial.meta.id, stale), + Err(TicketError::StaleClosureOperationKey { .. }) + )); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); +} + +#[test] +fn closure_keys_protect_closed_correction_and_open_closed_open_aba() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let stale_open = request( + &initial, + "late-close", + close(TicketClosureReason::Completed), + ); + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::Unknown)), + ) + .unwrap(); + let stale_correction = request( + &closed, + "late-correct", + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Completed, + }, + ); + let corrected = store + .update_case( + &initial.meta.id, + request( + &closed, + "correct", + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::NotPlanned, + }, + ), + ) + .unwrap(); + assert!(matches!( + store.update_case(&initial.meta.id, stale_correction), + Err(TicketError::StaleClosureOperationKey { .. }) + )); + let reopened = store + .update_case( + &initial.meta.id, + request(&corrected, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + assert_eq!( + ticket_content_digest(&reopened), + ticket_content_digest(&initial) + ); + assert!(matches!( + store.update_case(&initial.meta.id, stale_open), + Err(TicketError::StaleClosureOperationKey { .. }) + )); + assert_eq!( + store.show(initial.meta.id.clone().into()).unwrap(), + reopened + ); +} + +#[test] +fn exact_replay_after_edit_reopen_and_backend_restart_does_not_reapply_a_close() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + let closed = store + .update_case(&initial.meta.id, operation.clone()) + .unwrap(); + let reopened = store + .update_case( + &initial.meta.id, + request(&closed, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + let edited = store + .edit_item( + initial.meta.id.clone().into(), + TicketItemEdit { + title: Some("Changed requirements".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(edited.case_history, reopened.case_history); + let restored = + SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "workspace").unwrap(); + assert_eq!( + restored.update_case(&initial.meta.id, operation).unwrap(), + edited + ); +} + +#[test] +fn replay_fingerprint_binds_every_request_field_and_authenticated_source_identity() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + let closed = store + .update_case(&initial.meta.id, operation.clone()) + .unwrap(); + let mut changed = Vec::new(); + let mut x = operation.clone(); + x.expected_content_digest.push('x'); + changed.push(x); + let mut x = operation.clone(); + x.expected_state = TicketState::Closed; + changed.push(x); + let mut x = operation.clone(); + x.expected_closure_operation_key = Some("close".into()); + changed.push(x); + let mut x = operation.clone(); + x.action = close(TicketClosureReason::NotPlanned); + changed.push(x); + let mut x = operation.clone(); + x.action = TicketCaseAction::Reopen; + changed.push(x); + let mut x = operation.clone(); + x.explanation.push('x'); + changed.push(x); + let mut x = operation.clone(); + x.references.clear(); + changed.push(x); + let mut x = operation.clone(); + x.author = None; + changed.push(x); + for input in changed { + assert!(matches!( + store.update_case(&initial.meta.id, input), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + } + let other_source = store.clone().with_event_attributes(BTreeMap::from([( + "source_runtime_id".into(), + "other-runtime".into(), + )])); + assert!(matches!( + other_source.update_case(&initial.meta.id, operation), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed); +} + +#[test] +fn invalid_action_does_not_close_correct_or_reopen_the_wrong_case() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + for action in [ + TicketCaseAction::Reopen, + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Completed, + }, + ] { + assert!( + store + .update_case(&initial.meta.id, request(&initial, "invalid", action)) + .is_err() + ); + } + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::Unknown)), + ) + .unwrap(); + assert!( + store + .update_case( + &initial.meta.id, + request( + &closed, + "double-close", + close(TicketClosureReason::Completed) + ) + ) + .is_err() + ); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed); +} + +#[test] +fn custom_reason_limits_are_utf8_bytes_and_validation_keeps_long_markdown_explanations() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + for reason in [ + "".into(), + " \n".into(), + "bad\0reason".into(), + "あ".repeat(342), + ] { + assert!( + store + .update_case( + &initial.meta.id, + request(&initial, "bad", close(TicketClosureReason::Other(reason))) + ) + .is_err() + ); + } + let mut valid = request( + &initial, + "close", + close(TicketClosureReason::Other("x".repeat(1024))), + ); + valid.explanation = format!( + "## Decision\n{}\n", + "long".repeat(8000) + ); + let closed = store.update_case(&initial.meta.id, valid.clone()).unwrap(); + assert_eq!( + closed.current_closure.as_ref().unwrap().explanation, + valid.explanation + ); + assert_eq!(store.update_case(&initial.meta.id, valid).unwrap(), closed); +} + +#[test] +fn blank_explanation_actor_reference_or_key_rejects_without_recording_receipts() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let base = request(&initial, "key", close(TicketClosureReason::Unknown)); + let mut cases = Vec::new(); + let mut x = base.clone(); + x.explanation = " ".into(); + cases.push(x); + let mut x = base.clone(); + x.explanation = "bad\0text".into(); + cases.push(x); + let mut x = base.clone(); + x.author = Some(" ".into()); + cases.push(x); + let mut x = base.clone(); + x.references[0].target.clear(); + cases.push(x); + let mut x = base.clone(); + x.references[0].kind.clear(); + cases.push(x); + let mut x = base; + x.operation_key.clear(); + cases.push(x); + for input in cases { + assert!(store.update_case(&initial.meta.id, input).is_err()); + } + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); +} + +#[test] +fn case_history_is_immutable_in_sqlite_and_hook_failure_rolls_back_every_case_write() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let rejected = store.clone().with_mutation_hook(Arc::new(|_, _| { + Err(TicketError::Conflict("hook rejected".into())) + })); + let operation = request(&initial, "close", close(TicketClosureReason::Unknown)); + assert!( + rejected + .update_case(&initial.meta.id, operation.clone()) + .is_err() + ); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); + let closed = store.update_case(&initial.meta.id, operation).unwrap(); + let conn = Connection::open(temp.path().join("tickets.db")).unwrap(); + for sql in [ + "UPDATE typed_ticket_case_history SET judgment_json = '{}'", + "DELETE FROM typed_ticket_case_history", + ] { + assert!(conn.execute(sql, []).is_err()); + } + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed); +} + +#[test] +fn concurrent_exact_replay_creates_one_judgment_and_competing_closes_have_one_winner() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let barrier = Arc::new(Barrier::new(2)); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + let mut joins = Vec::new(); + for _ in 0..2 { + let store = store.clone(); + let barrier = barrier.clone(); + let operation = operation.clone(); + let id = initial.meta.id.clone(); + joins.push(std::thread::spawn(move || { + barrier.wait(); + store.update_case(&id, operation) + })); + } + for join in joins { + join.join().unwrap().unwrap(); + } + assert_eq!( + store + .show(initial.meta.id.clone().into()) + .unwrap() + .case_history + .len(), + 1 + ); + let initial = create(&store); + let barrier = Arc::new(Barrier::new(2)); + let mut joins = Vec::new(); + for key in ["winner-a", "winner-b"] { + let store = store.clone(); + let barrier = barrier.clone(); + let operation = request(&initial, key, close(TicketClosureReason::Unknown)); + let id = initial.meta.id.clone(); + joins.push(std::thread::spawn(move || { + barrier.wait(); + store.update_case(&id, operation) + })); + } + let outcomes: Vec<_> = joins.into_iter().map(|j| j.join().unwrap()).collect(); + assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1); + assert!( + outcomes + .iter() + .any(|result| matches!(result, Err(TicketError::StaleWorkflowState { .. }))) + ); + assert_eq!( + store + .show(initial.meta.id.clone().into()) + .unwrap() + .case_history + .len(), + 1 + ); +} + +#[test] +fn queue_and_readiness_are_routing_audit_and_never_change_case_or_reopen_closed() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let ready = store + .mark_ready( + initial.meta.id.clone().into(), + TicketMarkReady { + operation_key: "ready".into(), + reason: None, + intake_summary: None, + author: None, + }, + ) + .unwrap(); + assert_eq!(ready.meta.workflow_state, TicketState::Open); + assert_eq!(ready.meta.readiness.as_deref(), Some("ready")); + store + .queue_ready(initial.meta.id.clone().into(), "router") + .unwrap(); + let queued = store.show(initial.meta.id.clone().into()).unwrap(); + assert_eq!(queued.meta.workflow_state, TicketState::Open); + assert_eq!(queued.meta.queued_by.as_deref(), Some("router")); + let closed = store + .update_case( + &initial.meta.id, + request(&queued, "close", close(TicketClosureReason::NotPlanned)), + ) + .unwrap(); + assert!( + store + .queue_ready(initial.meta.id.clone().into(), "router") + .is_err() + ); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed); +} + +#[test] +fn workspace_boundary_rejects_other_workspace_ticket_and_shared_operation_dispatch_uses_case_api() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let other = + SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "other-workspace") + .unwrap(); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + assert!(matches!( + other.update_case(&initial.meta.id, operation.clone()), + Err(TicketError::NotFound(_)) + )); + let output = execute_ticket_backend_operation( + &store, + TicketBackendOperation::UpdateCase { + ticket: initial.meta.id.clone(), + request: operation, + }, + ) + .unwrap(); + assert!( + matches!(output, TicketBackendOperationResult::Ticket(ticket) if ticket.meta.workflow_state == TicketState::Closed) + ); +} + +#[test] +fn historical_events_and_merge_request_evidence_are_not_rewritten_by_case_judgments() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let historical = store.clone().with_event_attributes(BTreeMap::from([ + ("operation_key".into(), "old-completion".into()), + ("merge_request_id".into(), "MR-existing".into()), + ])); + let mut event = NewTicketEvent::new( + TicketEventKind::Other("ticket_completion".into()), + "Historic completed result", + ); + event.references = vec![TicketReference { + kind: "merge_request".into(), + target: "MR-existing".into(), + }]; + historical + .add_event(initial.meta.id.clone().into(), event) + .unwrap(); + let before = store.show(initial.meta.id.clone().into()).unwrap(); + let after = store + .update_case( + &initial.meta.id, + request(&before, "new-close", close(TicketClosureReason::NotPlanned)), + ) + .unwrap(); + assert_eq!( + &after.events[..before.events.len()], + before.events.as_slice() + ); + assert!( + store + .update_case( + &initial.meta.id, + request( + &before, + "old-completion", + close(TicketClosureReason::Completed) + ) + ) + .is_err() + ); +} + +#[test] +fn external_case_json_rejects_six_state_authority_and_round_trips_typed_action() { + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "in_progress", + ] { + assert!(serde_json::from_value::(serde_json::json!(state)).is_err()); + } + let encoded = serde_json::json!({"action": "correct_closure", "reason": {"other": "Custom"}}); + let action: TicketCaseAction = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(action).unwrap(), encoded); + assert!( + serde_json::from_value::( + serde_json::json!({"action": "reopen", "reason": "completed"}) + ) + .is_err() + ); +} + +fn assignment_source() -> BTreeMap { + BTreeMap::from([ + ("source_runtime_id".into(), "runtime-a".into()), + ("source_worker_id".into(), "worker-a".into()), + ("source_actor_role".into(), "ticket_worker".into()), + ("source_assignment_id".into(), "assignment-a".into()), + ]) +} +fn assignment_fixture(temp: &TempDir, id: &str) -> Connection { + let conn = Connection::open(temp.path().join("tickets.db")).unwrap(); + conn.execute_batch("CREATE TABLE test_worker_assignments (workspace_id TEXT, ticket_id TEXT, runtime_id TEXT, worker_id TEXT, assignment_id TEXT, status TEXT); + CREATE VIEW ticket_active_worker_assignments AS SELECT workspace_id, ticket_id, runtime_id, worker_id, assignment_id FROM test_worker_assignments WHERE status = 'active';").unwrap(); + conn.execute("INSERT INTO test_worker_assignments VALUES ('workspace', ?1, 'runtime-a', 'worker-a', 'assignment-a', 'active')", params![id]).unwrap(); + conn +} + +#[test] +fn assigned_worker_requires_exact_live_identity_but_recorded_replay_survives_release() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let conn = assignment_fixture(&temp, &initial.meta.id); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + for key in [ + "source_runtime_id", + "source_worker_id", + "source_assignment_id", + "source_actor_role", + ] { + let mut wrong = assignment_source(); + wrong.insert(key.into(), "mismatch".into()); + assert!( + store + .clone() + .with_event_attributes(wrong) + .update_case(&initial.meta.id, operation.clone()) + .is_err() + ); + } + for key in [ + "source_runtime_id", + "source_worker_id", + "source_assignment_id", + ] { + let mut missing = assignment_source(); + missing.remove(key); + assert!( + store + .clone() + .with_event_attributes(missing) + .update_case(&initial.meta.id, operation.clone()) + .is_err() + ); + } + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); + let source = store.clone().with_event_attributes(assignment_source()); + let closed = source + .update_case(&initial.meta.id, operation.clone()) + .unwrap(); + // The Ticket domain does not itself release or stop workers. Parent Workspace triggers own this. + assert_eq!( + conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r + .get::<_, String>( + 0 + )) + .unwrap(), + "active" + ); + conn.execute("UPDATE test_worker_assignments SET status = 'released'", []) + .unwrap(); + let reopened = store + .update_case( + &initial.meta.id, + request(&closed, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + assert_eq!( + source.update_case(&initial.meta.id, operation).unwrap(), + reopened + ); + assert!( + source + .update_case( + &initial.meta.id, + request(&reopened, "new-close", close(TicketClosureReason::Unknown)) + ) + .is_err() + ); + assert_eq!( + conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r + .get::<_, String>( + 0 + )) + .unwrap(), + "released" + ); +} + +#[test] +fn content_digest_changes_with_targets_or_requirements_not_case_judgment_history() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let digest = ticket_content_digest(&initial); + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::Unknown)), + ) + .unwrap(); + assert_eq!(ticket_content_digest(&closed), digest); + let edited = store + .edit_item( + initial.meta.id.clone().into(), + TicketItemEdit { + body: Some(MarkdownText::new("New criteria")), + ..Default::default() + }, + ) + .unwrap(); + assert_ne!(ticket_content_digest(&edited), digest); + let restored = store + .edit_item( + initial.meta.id.clone().into(), + TicketItemEdit { + body: Some(initial.document.body.clone()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(ticket_content_digest(&restored), digest); + let targeted = store + .edit_item( + initial.meta.id.clone().into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Set { + targets: vec![TicketTarget { + repository_key: "main".into(), + ref_selector: None, + access: TicketTargetAccess::ReadOnly, + }], + }), + ..Default::default() + }, + ) + .unwrap(); + assert_ne!(ticket_content_digest(&targeted), digest); +} + +#[test] +fn external_case_request_requires_an_explicit_nullable_closure_observation() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let original = request(&initial, "close", close(TicketClosureReason::Unknown)); + let mut json = serde_json::to_value(&original).unwrap(); + assert_eq!( + serde_json::from_value::(json.clone()).unwrap(), + original + ); + json.as_object_mut() + .unwrap() + .remove("expected_closure_operation_key"); + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn conflicting_event_context_rolls_back_case_judgment_instead_of_corrupting_its_receipt() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let context = store.clone().with_event_attributes(BTreeMap::from([( + "operation_key".into(), + "wrong-key".into(), + )])); + assert!( + context + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::Unknown)) + ) + .is_err() + ); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); +} diff --git a/crates/ticket/tests/fixtures/schema7.sql b/crates/ticket/tests/fixtures/schema7.sql new file mode 100644 index 000000000..0a1d1ea3f --- /dev/null +++ b/crates/ticket/tests/fixtures/schema7.sql @@ -0,0 +1,134 @@ +-- Frozen standalone schema7 fixture; deliberate persisted compatibility contract. +-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority. +CREATE TABLE typed_ticket_artifacts ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, relative_path), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_attributes ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, key), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_references ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + event_index INTEGER NOT NULL, + kind TEXT NOT NULL, + author TEXT, + at TEXT, + status TEXT, + from_state TEXT, + to_state TEXT, + reason TEXT, + state_field TEXT, + heading TEXT, + body TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_labels ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_orchestration_plans ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + related_ticket TEXT, + note TEXT, + accepted_summary TEXT, + accepted_branch TEXT, + accepted_worktree TEXT, + accepted_role_plan TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, record_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_raw_frontmatter ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_relations ( + 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 +); +CREATE TABLE typed_ticket_risk_flags ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_tickets ( + 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, + PRIMARY KEY (workspace_id, ticket_id) +); +CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "workspace_resource_key_counters" ( + 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) + ); +CREATE TABLE "workspace_resource_keys" ( + 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), + resource_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, resource_key) + ); +CREATE INDEX idx_workspace_resource_keys_reverse + ON workspace_resource_keys(workspace_id, resource_kind, resource_key); +CREATE INDEX typed_ticket_events_workspace_kind_ticket + ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); +CREATE INDEX typed_ticket_relations_workspace_source_kind + ON typed_ticket_relations(workspace_id, ticket_id, kind, target); +CREATE INDEX typed_ticket_relations_workspace_target_kind + ON typed_ticket_relations(workspace_id, target, kind, ticket_id); +CREATE INDEX typed_tickets_workspace_created + ON typed_tickets(workspace_id, created_at DESC, ticket_id); +CREATE INDEX typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); +CREATE INDEX typed_tickets_workspace_title + ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id); +CREATE INDEX typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); diff --git a/crates/ticket/tests/fixtures/schema8_tags.sql b/crates/ticket/tests/fixtures/schema8_tags.sql new file mode 100644 index 000000000..9c2796871 --- /dev/null +++ b/crates/ticket/tests/fixtures/schema8_tags.sql @@ -0,0 +1,38 @@ +-- Frozen standalone component8 tag extension; combine with schema7.sql. +-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade. +CREATE TABLE workspace_tags ( + workspace_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + name TEXT NOT NULL, + normalized_name TEXT NOT NULL, + last_operation_key TEXT NOT NULL, + deleted INTEGER NOT NULL CHECK (deleted IN (0, 1)), + PRIMARY KEY (workspace_id, tag_id) +); +CREATE UNIQUE INDEX workspace_tags_active_name + ON workspace_tags(workspace_id, normalized_name) WHERE deleted = 0; +CREATE TABLE typed_ticket_tags ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + tag_id TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, tag_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, + FOREIGN KEY (workspace_id, tag_id) REFERENCES workspace_tags(workspace_id, tag_id) ON DELETE RESTRICT +); +CREATE INDEX typed_ticket_tags_workspace_tag + ON typed_ticket_tags(workspace_id, tag_id, ticket_id); +-- Immutable operation receipt and Workspace audit: exact input includes actor +-- attributes; before/result preserve stable identities and display-name snapshots. +-- No foreign keys to mutable rows: history survives deletion of current links. +CREATE TABLE workspace_tag_operations ( + workspace_id TEXT NOT NULL, + operation_key TEXT NOT NULL, + kind TEXT NOT NULL, + target_id TEXT NOT NULL, + input_json TEXT NOT NULL, + source_context_json TEXT NOT NULL, + before_json TEXT NOT NULL, + result_json TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, operation_key) +); diff --git a/crates/ticket/tests/fixtures/schema9_cases.sql b/crates/ticket/tests/fixtures/schema9_cases.sql new file mode 100644 index 000000000..a269bbfa8 --- /dev/null +++ b/crates/ticket/tests/fixtures/schema9_cases.sql @@ -0,0 +1,33 @@ +-- Frozen standalone component9 case extension; combine with schema7.sql. +-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority. +CREATE TABLE typed_ticket_case_metadata ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_case_history ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, operation_key), + UNIQUE (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_legacy_workflow_snapshot ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; diff --git a/crates/ticket/tests/migration.rs b/crates/ticket/tests/migration.rs new file mode 100644 index 000000000..2d9c312c5 --- /dev/null +++ b/crates/ticket/tests/migration.rs @@ -0,0 +1,818 @@ +//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots +//! compose into schema10, preserving original evidence and routing handoff. +use rusqlite::{Connection, params}; +use tempfile::TempDir; +use ticket::*; + +fn schema7(connection: &Connection) { + connection + .execute_batch(include_str!("fixtures/schema7.sql")) + .unwrap(); + connection.execute_batch("CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL); + INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'legacy');").unwrap(); +} + +#[test] +fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_without_success_inference() + { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("tickets.db"); + let connection = Connection::open(&path).unwrap(); + schema7(&connection); + connection + .execute_batch("CREATE TABLE legacy_worker_status (ticket_id TEXT, status TEXT);") + .unwrap(); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit, assignee, queued_by, queued_at, resolution) VALUES ('workspace', ?1, ?1, ?1, 'open', 'task', 'P2', 'criteria', ?1, 1, 'retained-assignee', 'legacy-router', 'legacy-queue-time', 'Original decision text')", params![state]).unwrap(); + connection.execute("INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, kind, from_state, to_state, reason, author, body) VALUES ('workspace', ?1, 0, 'state_changed', 'planning', ?1, 'original reason', 'legacy-actor', 'legacy body')", params![state]).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'operation_key', 'legacy-receipt')", params![state]).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'request_fingerprint', 'legacy-fingerprint')", params![state]).unwrap(); + // Actual pre-content receipt spelling: preserve it as historical input, + // never promote an old counter fingerprint into a current content CAS. + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'expected_content_digest', 'old-input')", params![state]).unwrap(); + connection.execute( + "UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'", + [state], + ).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_references VALUES ('workspace', ?1, 0, 0, 'merge_request', 'MR-existing')", params![state]).unwrap(); + connection.execute("INSERT INTO typed_ticket_targets VALUES ('workspace', ?1, 0, 'main', 'develop', 'read_write')", params![state]).unwrap(); + connection + .execute( + "INSERT INTO legacy_worker_status VALUES (?1, 'released')", + params![state], + ) + .unwrap(); + } + // Snapshot evidence before migration, including untyped `done` and existing MR references. + let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::>().unwrap(); + let old_attributes: Vec<(String, String, String)> = connection + .prepare( + "SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key", + ) + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap() + .collect::>() + .unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + let preserved_attributes: Vec<(String, String, String)> = connection + .prepare( + "SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key", + ) + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(old_attributes, preserved_attributes); + verify_sqlite_ticket_schema(&connection).unwrap(); + let marker: i64 = connection + .query_row("SELECT version FROM ticket_schema_migrations", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(marker, 10); + let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::>().unwrap(); + assert_eq!(after_events, historical_events); + let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + let ticket = store.show(state.into()).unwrap(); + let expected_state = if matches!(state, "done" | "closed") { + TicketState::Closed + } else { + TicketState::Open + }; + assert_eq!(ticket.meta.workflow_state, expected_state); + let old_key_request = TicketCaseUpdate { + expected_content_digest: ticket_content_digest(&ticket), + expected_state, + expected_closure_operation_key: ticket.closure_operation_key.clone(), + operation_key: "legacy-receipt".into(), + action: if expected_state == TicketState::Closed { + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Unknown, + } + } else { + TicketCaseAction::Close { + reason: TicketClosureReason::Unknown, + } + }, + explanation: "Cannot reinterpret a frozen operation receipt".into(), + references: vec![], + author: Some("human".into()), + }; + assert!(matches!( + store.update_case(state, old_key_request), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(store.show(state.into()).unwrap(), ticket); + let legacy = ticket.legacy_workflow_snapshot.as_ref().unwrap(); + assert_eq!(legacy.workflow_state, state); + assert!(legacy.workflow_state_explicit); + assert_eq!(legacy.status, "open"); + assert_eq!(legacy.queued_by.as_deref(), Some("legacy-router")); + assert_eq!(legacy.queued_at.as_deref(), Some("legacy-queue-time")); + assert_eq!(legacy.resolution.as_deref(), Some("Original decision text")); + assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee")); + assert_eq!(ticket.meta.targets[0].repository_key, "main"); + assert_eq!(ticket.events[0].from.as_deref(), Some("planning")); + assert_eq!(ticket.events[0].to.as_deref(), Some(state)); + assert_eq!(ticket.events[0].references[0].target, "MR-existing"); + assert_eq!( + ticket.events[0].attributes["request_fingerprint"], + "legacy-fingerprint" + ); + if expected_state == TicketState::Closed { + let judgment = ticket.current_closure.as_ref().unwrap(); + assert_eq!( + judgment.reason, + TicketClosureReason::Unknown, + "old done must never mean successful completion" + ); + assert_eq!(judgment.explanation, "Original decision text"); + let reopened = store + .update_case( + state, + TicketCaseUpdate { + expected_content_digest: ticket_content_digest(&ticket), + expected_state, + expected_closure_operation_key: ticket.closure_operation_key.clone(), + operation_key: "reopen-migrated".into(), + action: TicketCaseAction::Reopen, + explanation: "Reconsider the case".into(), + references: Vec::new(), + author: Some("human".into()), + }, + ) + .unwrap(); + assert!(reopened.current_closure.is_none()); + assert_eq!(reopened.case_history[0], ticket.case_history[0]); + assert_eq!( + reopened.legacy_workflow_snapshot, + ticket.legacy_workflow_snapshot + ); + } else { + assert!(ticket.current_closure.is_none()); + } + } + let released: i64 = connection + .query_row( + "SELECT COUNT(*) FROM legacy_worker_status WHERE status = 'released'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(released, 6); + assert!( + connection + .execute( + "UPDATE typed_ticket_legacy_workflow_snapshot SET snapshot_json = '{}'", + [] + ) + .is_err() + ); + migrate_sqlite_ticket_schema(&connection).unwrap(); + assert_eq!( + connection + .query_row( + "SELECT COUNT(*) FROM typed_ticket_legacy_workflow_snapshot", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 6 + ); +} + +#[test] +fn unknown_legacy_state_rolls_back_the_independent_case_migration() { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) VALUES ('workspace', 'unknown', 'unknown', 'unknown', 'open', 'task', 'P2', 'body', 'future-state', 1)", []).unwrap(); + assert!(migrate_sqlite_ticket_schema(&connection).is_err()); + assert_eq!( + connection + .query_row("SELECT version FROM ticket_schema_migrations", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 7 + ); + assert_eq!( + connection + .query_row("SELECT workflow_state FROM typed_tickets", [], |row| row + .get::<_, String>( + 0 + )) + .unwrap(), + "future-state" + ); + assert_eq!( + connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name = 'typed_ticket_case_history'", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); +} + +#[test] +fn falsely_numbered_schema8_without_tags_is_rejected_without_writes() { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + connection + .execute("UPDATE ticket_schema_migrations SET version = 8", []) + .unwrap(); + assert!(migrate_sqlite_ticket_schema(&connection).is_err()); + assert_eq!( + connection + .query_row("SELECT version FROM ticket_schema_migrations", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 8 + ); +} + +// Read actual SQLite values, not merely counts: receipt JSON, original context, +// name snapshots and event/reference/attribute rows must survive byte-for-byte. +fn rows(connection: &Connection, table: &str) -> Vec> { + let mut stmt = connection + .prepare(&format!("SELECT * FROM {table} ORDER BY rowid")) + .unwrap(); + let count = stmt.column_count(); + stmt.query_map([], |row| (0..count).map(|i| row.get(i)).collect()) + .unwrap() + .collect::>() + .unwrap() +} + +fn copy_tables(connection: &Connection, source: &std::path::Path, tables: &[&str]) { + connection + .execute( + "ATTACH DATABASE ?1 AS fixture_source", + [source.to_str().unwrap()], + ) + .unwrap(); + for table in tables { + connection + .execute_batch(&format!( + "INSERT INTO {table} SELECT * FROM fixture_source.{table}" + )) + .unwrap(); + } + connection + .execute_batch("DETACH DATABASE fixture_source") + .unwrap(); +} + +fn mutate(backend: &SqliteTicketBackend, key: &str, action: WorkspaceTagAction) -> WorkspaceTag { + backend + .mutate_workspace_tag(WorkspaceTagMutation { + operation_key: key.into(), + action, + }) + .unwrap() +} + +fn case_request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate { + TicketCaseUpdate { + expected_content_digest: ticket_content_digest(ticket), + expected_state: ticket.meta.workflow_state, + expected_closure_operation_key: ticket.closure_operation_key.clone(), + operation_key: key.into(), + action, + explanation: "Retain this judgment".into(), + references: vec![TicketReference { + kind: "result".into(), + target: "retained-result".into(), + }], + author: Some("authenticated-user".into()), + } +} + +#[test] +fn tagged_schema8_composes_cases_preserving_tombstones_membership_context_receipts_and_thread() { + let temp = TempDir::new().unwrap(); + let source_path = temp.path().join("tag-operations.db"); + let source = SqliteTicketBackend::open(&source_path, "workspace") + .unwrap() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_account_id".into(), "account".into()), + ("source_user_id".into(), "user".into()), + ("source_actor_role".into(), "human".into()), + ])); + let id = source + .create(NewTicket::new("historical tagged ticket")) + .unwrap() + .id; + // Generate real durable T-740 receipts/audit/event snapshots through its + // unchanged classification boundary, then seed only those rows into frozen8. + let create = WorkspaceTagMutation { + operation_key: "create".into(), + action: WorkspaceTagAction::Create { + name: "open".into(), + }, + }; + let original = source.mutate_workspace_tag(create.clone()).unwrap(); + let attach = TicketTagSetRequest { + operation_key: "attach".into(), + expected_tag_ids: vec![], + tag_ids: vec![original.id.clone()], + }; + let attach_result = source + .set_ticket_tags(id.clone().into(), attach.clone()) + .unwrap(); + let renamed = mutate( + &source, + "rename", + WorkspaceTagAction::Rename { + tag_id: original.id.clone(), + expected_last_operation_key: "create".into(), + name: "closed".into(), + }, + ); + let deleted = mutate( + &source, + "delete", + WorkspaceTagAction::Delete { + tag_id: renamed.id.clone(), + expected_last_operation_key: "rename".into(), + }, + ); + let recreated = mutate( + &source, + "recreate", + WorkspaceTagAction::Create { + name: "closed".into(), + }, + ); + assert_ne!(recreated.id, deleted.id); + source + .set_ticket_tags( + id.clone().into(), + TicketTagSetRequest { + operation_key: "attach-recreated".into(), + expected_tag_ids: vec![], + tag_ids: vec![recreated.id.clone()], + }, + ) + .unwrap(); + + let path = temp.path().join("retained8.db"); + let connection = Connection::open(&path).unwrap(); + connection + .pragma_update(None, "foreign_keys", "ON") + .unwrap(); + schema7(&connection); + connection + .execute_batch(include_str!("fixtures/schema8_tags.sql")) + .unwrap(); + connection + .execute("UPDATE ticket_schema_migrations SET version=8", []) + .unwrap(); + let preserved = [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_event_references", + ]; + copy_tables(&connection, &source_path, &["typed_tickets"]); + copy_tables(&connection, &source_path, &preserved); + // Six-state retained data, not a current10 DB with its number changed. + connection.execute("UPDATE typed_tickets SET workflow_state='done', status='done', resolution='Historical explanation', queued_by='old-router',queued_at='old-time'", []).unwrap(); + connection.execute("INSERT INTO typed_ticket_events (workspace_id,ticket_id,event_index,kind,author,body) VALUES ('workspace',?1,99,'close','old-user','Original close')", [&id]).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'operation_key','old-close-receipt')", [&id]).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace',?1,99,'request_fingerprint','old-close-fingerprint')", [&id]).unwrap(); + let before: Vec<_> = preserved.iter().map(|t| rows(&connection, t)).collect(); + verify_legacy_sqlite_ticket_schema(&connection).unwrap(); + assert!(verify_sqlite_ticket_schema(&connection).is_err()); + assert!(verify_pre_classification_ticket_schema(&connection).is_err()); + migrate_sqlite_ticket_schema(&connection).unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); + assert_eq!( + rows(&connection, "ticket_schema_migrations")[0][0], + rusqlite::types::Value::Integer(10) + ); + for (table, expected) in preserved.iter().zip(&before) { + assert_eq!(&rows(&connection, table), expected, "changed {table}"); + } + let backend = SqliteTicketBackend::open_verified(&path, "workspace") + .unwrap() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_account_id".into(), "account".into()), + ("source_user_id".into(), "user".into()), + ("source_actor_role".into(), "human".into()), + ])); + let ticket = backend.show(id.clone().into()).unwrap(); + assert_eq!(ticket.tags, vec![recreated]); + assert_eq!( + ticket_content_digest(&ticket), + ticket_content_digest(&source.show(id.clone().into()).unwrap()) + ); + assert!(matches!( + backend.update_case( + &id, + case_request(&ticket, "old-close-receipt", TicketCaseAction::Reopen) + ), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!( + ticket.current_closure.as_ref().unwrap().reason, + TicketClosureReason::Unknown + ); + assert_eq!( + ticket.current_closure.as_ref().unwrap().explanation, + "Historical explanation" + ); + assert_eq!( + ticket + .legacy_workflow_snapshot + .as_ref() + .unwrap() + .workflow_state, + "done" + ); + assert_eq!( + backend.mutate_workspace_tag(create.clone()).unwrap(), + original + ); + assert_eq!( + backend + .set_ticket_tags(id.clone().into(), attach.clone()) + .unwrap(), + attach_result + ); + let other = backend + .clone() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_account_id".into(), "other-account".into()), + ("source_user_id".into(), "user".into()), + ])); + assert!(matches!( + other.mutate_workspace_tag(create), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert!(matches!( + other.set_ticket_tags(id.clone().into(), attach), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert!( + backend + .set_ticket_tags( + id.clone().into(), + TicketTagSetRequest { + operation_key: "attach-deleted".into(), + expected_tag_ids: ticket.tags.iter().map(|t| t.id.clone()).collect(), + tag_ids: vec![deleted.id.clone()], + } + ) + .is_err() + ); + assert!( + connection + .execute( + "INSERT INTO typed_ticket_tags VALUES ('foreign',?1,?2)", + params![id, deleted.id] + ) + .is_err() + ); + let judgments = rows(&connection, "typed_ticket_case_history"); + migrate_sqlite_ticket_schema(&connection).unwrap(); + assert_eq!(rows(&connection, "typed_ticket_case_history"), judgments); + for (table, expected) in preserved.iter().zip(&before) { + assert_eq!( + &rows(&connection, table), + expected, + "replay changed {table}" + ); + } + let reopened = backend + .update_case( + &id, + case_request(&ticket, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + assert_eq!(reopened.tags, ticket.tags); + assert_eq!(reopened.case_history[0], ticket.case_history[0]); + assert_eq!( + ticket_content_digest(&reopened), + ticket_content_digest(&ticket) + ); + assert!(reopened.current_closure.is_none()); + let fk_failures: i64 = connection + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(fk_failures, 0); +} + +#[test] +fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_receipts() { + let temp = TempDir::new().unwrap(); + let source_path = temp.path().join("case-operations.db"); + let source = SqliteTicketBackend::open(&source_path, "workspace") + .unwrap() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ])); + let id = source.create(NewTicket::new("existing case")).unwrap().id; + let before = source.show(id.clone().into()).unwrap(); + let close = case_request( + &before, + "close", + TicketCaseAction::Close { + reason: TicketClosureReason::Duplicate, + }, + ); + let closed = source.update_case(&id, close.clone()).unwrap(); + let corrected = source + .update_case( + &id, + case_request( + &closed, + "correct", + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::NotPlanned, + }, + ), + ) + .unwrap(); + let reopened = source + .update_case( + &id, + case_request(&corrected, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + let path = temp.path().join("retained9.db"); + let connection = Connection::open(&path).unwrap(); + schema7(&connection); + connection + .execute_batch(include_str!("fixtures/schema9_cases.sql")) + .unwrap(); + connection + .execute("UPDATE ticket_schema_migrations SET version=9", []) + .unwrap(); + let tables = [ + "typed_tickets", + "typed_ticket_case_metadata", + "typed_ticket_case_history", + "typed_ticket_legacy_workflow_snapshot", + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_event_references", + "workspace_resource_keys", + "workspace_resource_key_counters", + ]; + copy_tables(&connection, &source_path, &tables); + // A migrated closed case already judged Unknown by standalone9 must remain + // exactly that judgment, with its original migration actor/key and handoff. + connection.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution) VALUES ('workspace','old9','old9','Old migrated case','closed','task','P2','criteria','closed',1,'Original legacy close')", []).unwrap(); + let snapshot = TicketLegacyWorkflowSnapshot { + workflow_state: "done".into(), + workflow_state_explicit: true, + status: "done".into(), + queued_by: Some("original-router".into()), + queued_at: Some("original-time".into()), + resolution: Some("Original legacy close".into()), + }; + let judgment = TicketCaseHistoryEntry { + operation_key: "migration-v9:old9".into(), + previous_closure_operation_key: None, + action: TicketCaseAction::Close { + reason: TicketClosureReason::Unknown, + }, + explanation: "Original legacy close".into(), + references: vec![], + author: "ticket-schema-migration-v9".into(), + at: "original-migration-time".into(), + }; + connection + .execute( + "INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES ('workspace','old9',?1)", + [serde_json::to_string(&snapshot).unwrap()], + ) + .unwrap(); + connection.execute("INSERT INTO typed_ticket_case_history VALUES ('workspace','old9',0,'migration-v9:old9','migration-v9',?1)", [serde_json::to_string(&judgment).unwrap()]).unwrap(); + connection.execute("INSERT INTO typed_ticket_case_metadata VALUES ('workspace','old9','migration-v9:old9')", []).unwrap(); + let original: Vec<_> = tables.iter().map(|t| rows(&connection, t)).collect(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + for (table, expected) in tables.iter().zip(&original) { + assert_eq!( + &rows(&connection, table), + expected, + "changed independent9 {table}" + ); + } + assert!(rows(&connection, "workspace_tags").is_empty()); + let backend = SqliteTicketBackend::open_verified(&path, "workspace") + .unwrap() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "worker".into()), + ])); + let old9 = backend.show("old9".into()).unwrap(); + assert_eq!(old9.case_history, vec![judgment]); + assert_eq!(old9.legacy_workflow_snapshot, Some(snapshot)); + assert_eq!( + old9.current_closure.unwrap().reason, + TicketClosureReason::Unknown + ); + assert_eq!(backend.show(id.clone().into()).unwrap(), reopened); + assert_eq!(backend.update_case(&id, close.clone()).unwrap(), reopened); + let other = backend + .clone() + .with_event_attributes(std::collections::BTreeMap::from([ + ("source_runtime_id".into(), "runtime".into()), + ("source_worker_id".into(), "other-worker".into()), + ])); + assert!(matches!( + other.update_case(&id, close), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(rows(&connection, "typed_ticket_case_history"), original[2]); +} + +#[test] +fn migration_inside_host_transaction_is_resumable_and_never_commits_host_writes() { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + verify_pre_classification_ticket_schema(&connection).unwrap(); + assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err()); + connection.execute_batch("CREATE TABLE host_work (value TEXT); BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('pending');").unwrap(); + migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); + assert!(!connection.is_autocommit()); + connection.execute_batch("ROLLBACK").unwrap(); + verify_pre_classification_ticket_schema(&connection).unwrap(); + assert!(rows(&connection, "host_work").is_empty()); + connection.execute_batch("BEGIN IMMEDIATE").unwrap(); + migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap(); + migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap(); + connection.execute_batch("COMMIT").unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); +} + +#[test] +fn falsely_numbered_schema9_and_weakened_immutable_triggers_are_rejected_without_migration() { + for drift in [ + "UPDATE ticket_schema_migrations SET version=9", + "UPDATE ticket_schema_migrations SET version=9; DROP TRIGGER typed_ticket_case_history_no_update; CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history BEGIN SELECT 1; END;", + ] { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + if drift.contains("DROP TRIGGER") { + connection + .execute_batch(include_str!("fixtures/schema9_cases.sql")) + .unwrap(); + } + connection.execute_batch(drift).unwrap(); + let marker = rows(&connection, "ticket_schema_migrations"); + assert!(migrate_sqlite_ticket_schema(&connection).is_err()); + assert_eq!(rows(&connection, "ticket_schema_migrations"), marker); + let tags: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tags, 0); + } +} + +#[test] +fn failed_component_conversion_rolls_back_its_ddl_and_keeps_host_transaction_retryable() { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + connection.execute_batch("CREATE TABLE host_work (value TEXT); + INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES ('workspace','ticket','ticket','Case','open','task','P2','criteria','ready',1); + CREATE TRIGGER host_abort_case_update BEFORE UPDATE OF workflow_state ON typed_tickets + BEGIN SELECT RAISE(ABORT,'host failure'); END; + BEGIN IMMEDIATE; INSERT INTO host_work VALUES ('retained host work');").unwrap(); + assert!(migrate_sqlite_ticket_schema_in_transaction(&connection).is_err()); + assert!(!connection.is_autocommit()); + verify_pre_classification_ticket_schema(&connection).unwrap(); + assert_eq!(rows(&connection, "host_work").len(), 1); + let tags: i64 = connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name='workspace_tags'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(tags, 0, "DDL before failed state conversion must roll back"); + connection + .execute_batch("DROP TRIGGER host_abort_case_update") + .unwrap(); + migrate_sqlite_ticket_schema_in_transaction(&connection).unwrap(); + connection.execute_batch("COMMIT").unwrap(); + verify_sqlite_ticket_schema(&connection).unwrap(); + assert_eq!(rows(&connection, "host_work").len(), 1); +} + +#[test] +fn retained_workspace_foreign_keys_survive_composition_without_rebuilding_ticket_or_tags() { + let connection = Connection::open_in_memory().unwrap(); + connection + .pragma_update(None, "foreign_keys", "ON") + .unwrap(); + connection.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); INSERT INTO workspaces VALUES ('workspace');").unwrap(); + // Ticket accepts stricter host-owned Workspace references and must preserve + // them, not rebuild parents against its weaker standalone authority schema. + let schema = include_str!("fixtures/schema7.sql"); + let (before, ticket) = schema.split_once("CREATE TABLE typed_tickets (").unwrap(); + let (ticket, after) = ticket + .split_once("CREATE TABLE typed_ticket_targets (") + .unwrap(); + let ticket = ticket.replace("PRIMARY KEY (workspace_id, ticket_id)", + "PRIMARY KEY (workspace_id, ticket_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE"); + connection + .execute_batch(&format!( + "{before}CREATE TABLE typed_tickets ({ticket}CREATE TABLE typed_ticket_targets ({after}" + )) + .unwrap(); + let tags = include_str!("fixtures/schema8_tags.sql") + .replace("PRIMARY KEY (workspace_id, tag_id)", "PRIMARY KEY (workspace_id, tag_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE") + .replace("PRIMARY KEY (workspace_id, operation_key)", "PRIMARY KEY (workspace_id, operation_key), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE"); + connection.execute_batch(&tags).unwrap(); + connection.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); + INSERT INTO ticket_schema_migrations VALUES(8,'ticket schema baseline','retained'); + INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES('workspace','ticket','ticket','Case','done','task','P2','criteria','done',1); + INSERT INTO workspace_tags VALUES('workspace','tag','closed','closed','create',0); + INSERT INTO typed_ticket_tags VALUES('workspace','ticket','tag');").unwrap(); + let signature = |table| { + let mut stmt = connection + .prepare(&format!( + "SELECT * FROM pragma_foreign_key_list('{table}') ORDER BY id,seq" + )) + .unwrap(); + let count = stmt.column_count(); + stmt.query_map([], |row| { + (0..count) + .map(|i| row.get::<_, rusqlite::types::Value>(i)) + .collect::>>() + }) + .unwrap() + .collect::>>() + .unwrap() + }; + let tables = [ + "typed_tickets", + "workspace_tags", + "workspace_tag_operations", + "typed_ticket_tags", + ]; + let before: Vec<_> = tables.iter().map(|t| signature(t)).collect(); + verify_legacy_sqlite_ticket_schema(&connection).unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + for (table, expected) in tables.iter().zip(before) { + assert_eq!(signature(table), expected, "lost host FK on {table}"); + } + assert!( + connection + .execute( + "INSERT INTO workspace_tags VALUES('foreign','tag','open','open','create',0)", + [] + ) + .is_err() + ); + assert!( + connection + .execute( + "INSERT INTO typed_ticket_tags VALUES('foreign','ticket','tag')", + [] + ) + .is_err() + ); + let failures: i64 = connection + .query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(failures, 0); +} diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 22f0f14b6..f9ff246db 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -435,16 +435,23 @@ mod client { use super::*; use crate::{Workdir, WorkdirSession}; - /// Provides a fresh bearer token for each Runtime request. Backend - /// implementations can mint short-lived capability tokens without making a - /// Worker-bound session expire with the token used to open it. + /// An owned, Send authorization future that can offload synchronous authority + /// work without borrowing request data or the authorization provider. + pub type WorkdirHttpAuthorizationFuture = std::pin::Pin< + Box> + Send + 'static>, + >; + + /// Provides a fresh bearer token asynchronously for each Runtime request. + /// Backend implementations can mint short-lived capability tokens without + /// making a Worker-bound session expire with the token used to open it. + /// Blocking DB/signing work must be offloaded by the implementation. pub trait WorkdirHttpAuthorization: std::fmt::Debug + Send + Sync { fn bearer_token( &self, - method: &str, - path_and_query: &str, - body: &[u8], - ) -> Result; + method: String, + path_and_query: String, + body: Vec, + ) -> WorkdirHttpAuthorizationFuture; } struct FixedBearerToken(Arc); @@ -458,11 +465,12 @@ mod client { impl WorkdirHttpAuthorization for FixedBearerToken { fn bearer_token( &self, - _method: &str, - _path_and_query: &str, - _body: &[u8], - ) -> Result { - Ok(self.0.to_string()) + _method: String, + _path_and_query: String, + _body: Vec, + ) -> WorkdirHttpAuthorizationFuture { + let token = self.0.clone(); + Box::pin(async move { Ok(token.to_string()) }) } } @@ -512,7 +520,9 @@ mod client { )?; let body = serde_json::to_vec(&request) .map_err(|error| WorkdirError::Unavailable(error.to_string()))?; - let token = authorization.bearer_token("POST", url.path(), &body)?; + let token = authorization + .bearer_token("POST".to_string(), url.path().to_string(), body.clone()) + .await?; let response = client .post(url) .bearer_auth(token) @@ -563,7 +573,10 @@ mod client { let operation = WorkdirSessionOperationRequest { operation }; let body = serde_json::to_vec(&operation) .map_err(|error| WorkdirError::Unavailable(error.to_string()))?; - let token = self.authorization.bearer_token("POST", url.path(), &body)?; + let token = self + .authorization + .bearer_token("POST".to_string(), url.path().to_string(), body.clone()) + .await?; let response = self .client .post(url) @@ -867,7 +880,10 @@ mod client { &self.base_url, &["v1", "workdir-sessions", self.session_id.as_str()], )?; - let token = self.authorization.bearer_token("DELETE", url.path(), &[])?; + let token = self + .authorization + .bearer_token("DELETE".to_string(), url.path().to_string(), Vec::new()) + .await?; let response = self .client .delete(url) @@ -921,11 +937,124 @@ mod client { WorkdirError::Transport("Runtime Workdir HTTP request failed".to_string()) } + #[cfg(test)] + mod authorization_tests { + use super::*; + use std::sync::Mutex; + + #[derive(Debug, Default)] + struct RejectAuthorization { + requests: Arc)>>>, + } + + impl WorkdirHttpAuthorization for RejectAuthorization { + fn bearer_token( + &self, + method: String, + path_and_query: String, + body: Vec, + ) -> WorkdirHttpAuthorizationFuture { + let requests = self.requests.clone(); + Box::pin(async move { + tokio::task::yield_now().await; + let mut requests = requests.lock().unwrap(); + requests.push((method, path_and_query, body)); + Err(WorkdirError::Unavailable(format!( + "authorization rejected request {}", + requests.len() + ))) + }) + } + } + + #[tokio::test] + async fn open_operate_close_await_fresh_authorization_and_do_not_send_when_rejected() { + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let base_url: Url = format!("http://{}", listener.local_addr().unwrap()) + .parse() + .unwrap(); + let client = Client::builder().no_proxy().build().unwrap(); + let authorization = Arc::new(RejectAuthorization::default()); + let workdir = Workdir::new("w"); + let open_request = OpenWorkdirSessionRequest { + owner_worker_id: Some("worker".to_string()), + }; + let error = RemoteWorkdirSession::open_with_authorization( + client.clone(), + base_url.clone(), + authorization.clone(), + workdir.id().clone(), + open_request.clone(), + ) + .await + .unwrap_err(); + assert!(matches!( + error, + WorkdirError::Unavailable(message) if message == "authorization rejected request 1" + )); + + // Seed an already-open session to exercise the public operation and + // close boundaries independently of the rejected open request. + let remote = RemoteWorkdirSession { + client, + base_url, + authorization: authorization.clone(), + workdir, + session_id: WorkdirSessionId::new("s").unwrap(), + capabilities: WorkdirSessionCapabilities::EMPTY, + closed: AtomicBool::new(false), + }; + let stat = StatRequest { + path: crate::WorkdirPath::new("file").unwrap(), + }; + let error = remote.stat(stat.clone()).await.unwrap_err(); + assert!(matches!( + error, + WorkdirError::Unavailable(message) if message == "authorization rejected request 2" + )); + let error = remote.close().await.unwrap_err(); + assert!(matches!( + error, + WorkdirError::Unavailable(message) if message == "authorization rejected request 3" + )); + assert_eq!( + *authorization.requests.lock().unwrap(), + vec![ + ( + "POST".to_string(), + "/v1/working-directories/w/sessions".to_string(), + serde_json::to_vec(&open_request).unwrap(), + ), + ( + "POST".to_string(), + "/v1/workdir-sessions/s/operations".to_string(), + serde_json::to_vec(&WorkdirSessionOperationRequest { + operation: WorkdirSessionOperation::Stat(stat), + }) + .unwrap(), + ), + ( + "DELETE".to_string(), + "/v1/workdir-sessions/s".to_string(), + Vec::new(), + ), + ] + ); + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + } + pub use self::RemoteWorkdirSession as ClientSession; } #[cfg(feature = "http-client")] -pub use client::{ClientSession as RemoteWorkdirSession, WorkdirHttpAuthorization}; +pub use client::{ + ClientSession as RemoteWorkdirSession, WorkdirHttpAuthorization, WorkdirHttpAuthorizationFuture, +}; #[cfg(test)] mod tests { diff --git a/crates/workdir/src/workspace.rs b/crates/workdir/src/workspace.rs index 33e54475e..5b4eda5f7 100644 --- a/crates/workdir/src/workspace.rs +++ b/crates/workdir/src/workspace.rs @@ -3,7 +3,9 @@ //! Types shared by Server and Runtime expose no provider/session handles, host paths, Runtime URLs, //! or credentials. A Workdir's inventory presence is separate from content access through a Worker's //! live attachment. Status and past materialization evidence alone do not grant current scope -//! or cleanup authority. +//! or cleanup authority. A retained partial-removal Workdir is CleanupPending with cleanliness +//! unknown; saved removal metadata is not a current cleanliness assertion. Provider-confirmed +//! physical deletion or authoritative not-found, not a missing response, permits registry removal. use schemars::JsonSchema; use serde::{Deserialize, Serialize}; diff --git a/crates/worker-runtime/src/http_server.rs b/crates/worker-runtime/src/http_server.rs index e4eb0a2c6..b635a4e75 100644 --- a/crates/worker-runtime/src/http_server.rs +++ b/crates/worker-runtime/src/http_server.rs @@ -5,6 +5,10 @@ //! command surface for a trusted backend/proxy. Browsers must not connect to the //! Runtime process directly; a backend is expected to own any browser-facing //! credentials, registration, and policy. +//! +//! Workdir list/detail/removal offload synchronous provider work to blocking workers. +//! Removal reserves only its target under the Runtime state mutex, releasing it before +//! filesystem/Git inspection and unlink; conflicting target use is rejected promptly. mod runtime_management_api; diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 2f384dd36..feedf3526 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -2545,16 +2545,24 @@ where } } method => { - let result = self.send_method( - WorkerExecutionOperation::ProtocolMethod, - execution.handle.clone(), - method, - ); - if result.is_accepted() { + // The connected protocol owns this bounded admission wait. The old + // generic adapter waiter could time out while leaving a detached send + // task alive; it might deliver later after the connection had ended. + // Join cancellation before releasing endpoint exclusion. A send which + // raced cancellation remains delivery-unknown, never automatically retried. + let handle = execution.handle.clone(); + self.run_cancellable_on_adapter_runtime(USER_INPUT_TASK_TIMEOUT, async move { + handle.send(method).await.map_err(|error| { + format!("connected Worker method delivery failed or is unknown: {error}") + })?; Ok(Vec::new()) - } else { - Err(result) - } + }) + .map_err(|message| { + WorkerExecutionResult::errored( + WorkerExecutionOperation::ProtocolMethod, + message, + ) + }) } } } @@ -7905,6 +7913,48 @@ mod tests { assert!(backend.worker_snapshot(&worker_ref).is_none()); } + #[test] + fn connected_protocol_admission_timeout_joins_pending_send_before_capacity_reopens() { + // This is the adapter's cancellation/join policy boundary used by the + // captured protocol endpoint, not an execution-side-effect rollback. + let backend = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap(); + let (sender, mut receiver) = tokio::sync::mpsc::channel(1); + sender.try_send(Method::ListPendingSubmissions).unwrap(); + let dropped = Arc::new(AtomicBool::new(false)); + struct PendingSendDrop(Arc); + impl Drop for PendingSendDrop { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + let owned = PendingSendDrop(dropped.clone()); + let error = backend + .run_cancellable_on_adapter_runtime(Duration::from_millis(5), async move { + let _owned = owned; + sender + .send(Method::ListWorkers) + .await + .map_err(|error| error.to_string()) + }) + .unwrap_err(); + assert!(error.contains("was cancelled"), "{error}"); + assert!( + dropped.load(Ordering::SeqCst), + "must join task cancellation before returning" + ); + assert!(matches!( + receiver.try_recv(), + Ok(Method::ListPendingSubmissions) + )); + assert!( + matches!( + receiver.try_recv(), + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) + ), + "timeout must not leave an admission task capable of a late send" + ); + } + #[test] fn absent_execution_has_typed_input_rejection_and_safe_stop() { let backend = WorkerRuntimeExecutionBackend::new(FailingFactory).unwrap(); diff --git a/crates/worker-runtime/src/worker_source.rs b/crates/worker-runtime/src/worker_source.rs index e18692d89..47cc144db 100644 --- a/crates/worker-runtime/src/worker_source.rs +++ b/crates/worker-runtime/src/worker_source.rs @@ -18,6 +18,15 @@ use crate::auth::{ use crate::runtime::RuntimeWorkspaceScope; use crate::worker_backend::WorkspacePromptProjectionCache; +fn ticket_assignment_role_segment(role: server_api::TicketAssignmentRole) -> &'static str { + match role { + server_api::TicketAssignmentRole::Orchestrator => "orchestrator", + server_api::TicketAssignmentRole::Worker => "worker", + server_api::TicketAssignmentRole::Owner => "owner", + server_api::TicketAssignmentRole::Contributor => "contributor", + } +} + pub const DEFAULT_WORKER_MUTATION_SOURCE_TTL_SECONDS: u64 = 60; #[derive(Clone, Debug, PartialEq, Eq)] @@ -590,6 +599,62 @@ impl WorkspaceClient for RuntimeOwnedWorkspaceClient { .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; runtime.block_on(async move { match operation { + WorkspaceServerOperation::TicketAssignmentSet { + ticket_id, + role, + request, + } => { + let role = ticket_assignment_role_segment(role); + generated_workspace_response( + client + .ticket_assignment_set( + workspace_id, + ticket_id, + role.to_owned(), + request, + ) + .await, + ) + } + WorkspaceServerOperation::TicketAssignmentClear { + ticket_id, + role, + query, + } => { + let role = ticket_assignment_role_segment(role); + generated_workspace_response( + client + .ticket_assignment_clear( + workspace_id, + ticket_id, + role.to_owned(), + query, + ) + .await, + ) + } + WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => { + generated_workspace_response( + client + .ticket_assignments_page(workspace_id, ticket_id, query) + .await, + ) + } + WorkspaceServerOperation::TicketAssignmentDiagnostics { + ticket_id, + query, + } => generated_workspace_response( + client + .ticket_assignment_diagnostics(workspace_id, ticket_id, query) + .await, + ), + WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => { + generated_workspace_response( + client + .ticket_assignment_answer(workspace_id, ticket_id, request) + .await, + ) + } WorkspaceServerOperation::WorkerControlList => { generated_workspace_response( client.worker_control_list(workspace_id).await, @@ -882,6 +947,125 @@ mod tests { (base_url, server) } + #[test] + fn generated_ticket_assignment_transport_signs_exact_authoring_and_bounded_read_subjects() { + use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source}; + let identity = RuntimeIdentityMaterial::generate("runtime-a").unwrap(); + let assignment_result = + serde_json::json!({"workspace_id":"workspace-a", "ticket_id":"T-1", "assignment":null}); + let answer: server_api::TicketAssignmentAnswerRequest = serde_json::from_value(serde_json::json!({ "assignment_id": "delegation", "expected_last_event_id": "observed", "operation_key": "answer-op", "body": "User answer\ncontinued" })).unwrap(); + let cases = vec![ + ( + WorkspaceServerOperation::TicketAssignmentSet { + ticket_id: "T-1".into(), + role: server_api::TicketAssignmentRole::Orchestrator, + request: server_api::SetTicketRoleAssignmentRequest { + operation_id: "assign-op".into(), + expected_assignment_id: Some("delegation".into()), + orchestrator_assignment_id: None, + principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + workdir_bindings: vec![], + }, + }, + "PUT", + "/api/w/workspace-a/tickets/T-1/assignments/orchestrator", + assignment_result.clone(), + ), + ( + WorkspaceServerOperation::TicketAssignmentClear { + ticket_id: "T-1".into(), + role: server_api::TicketAssignmentRole::Orchestrator, + query: server_api::ClearTicketRoleAssignmentQuery { + operation_id: Some("clear-op".into()), + assignment_id: Some("delegation".into()), + }, + }, + "DELETE", + "/api/w/workspace-a/tickets/T-1/assignments/orchestrator?operation_id=clear-op&assignment_id=delegation", + assignment_result, + ), + ( + WorkspaceServerOperation::TicketAssignmentsPage { + ticket_id: "T-1".into(), + query: server_api::TicketAssignmentsQuery { + after_assignment_id: Some("old id".into()), + limit: Some(1), + }, + }, + "GET", + "/workspaces/workspace-a/tickets/T-1/assignment-tracking/assignments?after_assignment_id=old+id&limit=1", + serde_json::json!({"items":[],"next_assignment_id":null}), + ), + ( + WorkspaceServerOperation::TicketAssignmentDiagnostics { + ticket_id: "T-1".into(), + query: server_api::TicketAssignmentDiagnosticsQuery { + assignment_id: "delegation".into(), + after_input_event_id: Some("ticket-event:1".into()), + after_start_operation_id: None, + limit: Some(1), + }, + }, + "GET", + "/workspaces/workspace-a/tickets/T-1/assignment-diagnostics?assignment_id=delegation&after_input_event_id=ticket-event%3A1&limit=1", + serde_json::json!({"assignment_id":"delegation","current":true,"input_origin_known":true,"actionable":true,"inputs":[],"starts":[],"next_input_event_id":null,"next_start_operation_id":null}), + ), + ( + WorkspaceServerOperation::TicketAssignmentAnswer { + ticket_id: "T-1".into(), + request: answer.clone(), + }, + "POST", + "/workspaces/workspace-a/tickets/T-1/assignment-answer", + serde_json::json!({"assignment_id":"delegation","input_event_id":"ticket-event:1","event_index":1}), + ), + ]; + for (operation, method, path, reply) in cases { + let reply = reply.to_string(); + let response = format!("HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", reply.len(), reply).into_bytes(); + let (url, server) = binary_loopback(response); + let client = + RuntimeOwnedWorkspaceClient::new("workspace-a", url, "runtime-a", "worker-a") + .with_runtime_request_source(&identity, "server-a"); + let result = client.execute_server_operation(operation).unwrap(); + assert_eq!(result.status, 200); + let (head, body) = server.join().unwrap(); + assert!( + head.starts_with(&format!("{method} {path} HTTP/1.1\r\n")), + "unexpected request: {head}" + ); + let token = head + .lines() + .find_map(|line| { + let (key, value) = line.split_once(':')?; + key.eq_ignore_ascii_case(RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + .then(|| value.trim()) + }) + .unwrap(); + let digest = request_body_digest(&body); + let expected = RuntimeRequestSourceExpectation { + identity_id: "runtime-a", + audience: "server-a", + workspace_id: "workspace-a", + worker_id: Some("worker-a"), + permission: WORKSPACE_REQUEST_PERMISSION, + method, + path, + body_digest: &digest, + now_unix: unix_now_seconds() as i64, + }; + verify_runtime_request_source(token, &identity.public_key, &expected).unwrap(); + if method == "POST" { + assert_eq!( + serde_json::from_slice::(&body).unwrap(), + serde_json::to_value(&answer).unwrap() + ); + } + } + } + #[test] fn binary_roundtrip_signs_exact_query_method_worker_and_raw_body() { use crate::auth::{RuntimeRequestSourceExpectation, verify_runtime_request_source}; diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 749d32903..a541ca2e1 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -10,10 +10,11 @@ //! Resource clones held by sessions and commands maintain that protection. Deletion invalidates //! the materialization generation, so recreating the same ID cannot activate old bindings. //! -//! Deletion is a provider operation checking current identity, mounts, and remaining content. -//! Partial-deletion witnesses are stored outside the tree. CleanupPending/unknown inventory status -//! does not guarantee current cleanliness. Detailed inspection and remaining-content checks -//! are limited to cleanup retries. +//! Deletion checks current identity, mounts, Git cleanliness when needed, and surviving entry +//! metadata. Durable metadata authority lives outside the tree; cleanup performs no content-hash +//! scan or byte-equality verification. CleanupPending/unknown inventory checks saved identity only, +//! not current cleanliness. Normal retries recheck safety; a failed attempt grants no deletion +//! permission and does not permanently forbid normal reattachment to a usable same-identity checkout. use crate::catalog::{ MaterializerKind, RepositoryRefObservation, RepositoryRefObservationRequest, @@ -758,22 +759,12 @@ impl RuntimeGitMaterializer { // Frozen evidence normalization can write the record. Keep it inside // use admission so observational reads cannot race physical cleanup. let _use_lease = self.occupancy.acquire_use(working_directory_id)?; - self.read_binding_record(working_directory_id, true) - } - - fn read_binding_for_cleanup( - &self, - working_directory_id: &str, - ) -> Result { - // Cleanup already owns exclusive admission and validates the original - // bytes. Its witnessed status write performs any canonicalization. - self.read_binding_record(working_directory_id, false) + self.read_binding_record(working_directory_id) } fn read_binding_record( &self, working_directory_id: &str, - migrate: bool, ) -> Result { let working_directory_root = self.working_directory_root(working_directory_id); let path = working_directory_root.join(MATERIALIZATION_RECORD); @@ -810,7 +801,7 @@ impl RuntimeGitMaterializer { "materialization evidence is invalid", ) })?; - if migrate && canonical != raw { + if canonical != raw { let temporary = path.with_extension(format!("migration-{}.tmp", uuid::Uuid::now_v7())); let result = (|| -> std::io::Result<()> { let mut file = fs::OpenOptions::new() @@ -4800,9 +4791,16 @@ mod tests { materializer.read_binding(id).unwrap_err().code, "working_directory_cleanup_resource_busy" ); - materializer.read_binding_for_cleanup(id).unwrap(); - assert_eq!(fs::read(&path).unwrap(), original); drop(cleanup); + // Actual cleanup admission reads the legacy record without rewriting it + // before change protection has admitted removal. + fs::write(binding.root.join("README.md"), "preserve change").unwrap(); + assert_eq!( + materializer.cleanup_working_directory(id).unwrap_err().code, + "working_directory_cleanup_changes_present" + ); + assert_eq!(fs::read(&path).unwrap(), original); + git(binding.root(), &["restore", "README.md"]); materializer.read_binding(id).unwrap(); let canonical: serde_json::Value = diff --git a/crates/worker-runtime/src/working_directory/cleanup.rs b/crates/worker-runtime/src/working_directory/cleanup.rs index 345c219ee..40b6957ee 100644 --- a/crates/worker-runtime/src/working_directory/cleanup.rs +++ b/crates/worker-runtime/src/working_directory/cleanup.rs @@ -1,13 +1,32 @@ -//! Mount-safe deletion and content checks after partial deletion. +//! Mount-safe ordinary removal, not a separate recovery lifecycle. //! -//! Content witnesses are stored outside the deletion tree, surviving partial unlinking. -//! Witnesses describe past facts; every retry revalidates current identity, mounts, and remaining -//! content. Missing entries count as partial deletion, but new or changed content is not discarded. -//! Only the same intact clean checkout can refresh an old witness. List/detail check saved identity -//! only; they infer neither remaining file hashes nor current cleanliness. +//! Durable entry metadata in `.cleanup-authority`, outside the removed tree, permits a matching +//! surviving subset after partial unlink even without usable Git. Every retry checks current +//! ownership, root/checkout/Git identities where present, mounts, and surviving entry metadata; +//! any surviving Runtime record must describe the same materialization. List/detail check saved +//! identity only and report CleanupPending/unknown, not current cleanliness or removal permission. //! -//! Rejects deletion if Linux openat2 or /proc mount checks are unavailable; no unchecked recursive -//! deletion fallback. Does not implicitly unmount resources or delete other owners' data. +//! The cleanup walker never reads ordinary file bodies or computes content digests. It records +//! device/inode, type, regular-file executable bits, and size/nanosecond mtime for files and symlinks. +//! Directories retain identity/type only because child unlink changes their timestamps. Symlink +//! targets are neither read nor followed. This is metadata protection, not byte equality: +//! same-size writes restoring mtime are not guaranteed to be detected. Normal Git inspection may +//! read repository content; bounded Runtime metadata reads are separate from the cleanup walker. +//! +//! Initial admission requires usable clean Git, including non-ignored untracked files, except +//! for validated metadata-only residue. Ignored entries present at admission are metadata-protected. +//! Renewal requires the same usable clean checkout and ownership; clean Git cannot authorize new +//! or changed ignored/outside entries. A mismatch without usable Git is changes_unknown, not proof +//! that residue is expendable. Legacy digest fields are discarded, never recomputed or compared; +//! migration requires usable clean Git and rejects surviving ignored/outside entries whose change +//! safety cannot be established. Validated metadata-only residue may finish without Git. +//! +//! Linux openat2 and /proc mount checks are prerequisites, including per-unlink descriptor checks; +//! unavailable inspection fails closed with no unchecked recursive deletion fallback. Runtime does +//! not implicitly unmount external/unknown resources. Resolve causes through the resource owner +//! and retry normal removal; do not override checks by editing materialization or authority records. +//! A usable same-identity checkout may be normally reattached to preserve changes after failure; +//! executing removal fences attachment, and occupancy is checked separately on each attempt. //! OS errors stay in Runtime-private logs; public diagnostics contain only closed causes and safe messages. use super::*; use std::io; @@ -36,7 +55,7 @@ fn cause(kind: &str) -> WorkingDirectoryDiagnostic { "New or changed content is present. Preserve or resolve those changes before retrying removal." } _ => { - "The checkout is incomplete and remaining content has no usable removal witness. Preserve residual content outside this Workdir and restore the same usable checkout, or ask its owner to remove verified residual content before retrying. Do not edit removal metadata." + "Remaining entries cannot be authorized from saved entry metadata and a usable clean checkout. Preserve residual content outside this Workdir and restore the same usable checkout, or ask its owner to remove verified residual content before retrying. Do not edit removal metadata." } }; WorkingDirectoryDiagnostic::new(format!("working_directory_cleanup_{kind}"), message) @@ -83,7 +102,7 @@ pub(super) fn remaining_status( let mut summary = saved.working_directory.status_summary(); summary.status = WorkingDirectoryStatusKind::CleanupPending; // Observation is not removal admission: do not inventory or hash the - // surviving checkout just to list it. The witness can be stale, so neither + // surviving checkout just to list it. The entry authority can be stale, so neither // its old cleanliness nor a successful identity check proves current content // is safe to remove. Actual retries still perform the full safety checks. summary.cleanliness = Some("unknown".to_string()); @@ -171,7 +190,7 @@ fn remove_with_checks( } })?; // Mount table preflight catches mounted files and same-device bind mounts - // before Git/status/hash reads; descriptor NO_XDEV remains the race guard. + // before Git status or Runtime metadata reads; descriptor NO_XDEV remains the race guard. check_mounts( &materializer.working_directory_root(id), &mount_info().map_err(|e| { @@ -180,28 +199,30 @@ fn remove_with_checks( })?, )?; let authority = if let Some(saved) = saved { - if saved.working_directory.id != id || saved.root_identity != root_identity { + validate_directory_owner(id, &saved.working_directory)?; + if saved.root_identity != root_identity { return Err(cause("ownership_unknown")); } let entries = retry_inventory(materializer, id, &tree, &saved)?; - if verify_remaining(&saved.entries, &entries).is_ok() { + if !saved.legacy && verify_remaining(&saved.entries, &entries).is_ok() { saved } else { - let authority = Authority { entries, ..saved }; - // Renew obsolete content evidence only from a current clean, intact - // checkout. Partial deletion without Git retains the old witness. + let authority = Authority { + entries, + legacy: false, + ..saved + }; + // Renew only after retry admission proves a usable clean checkout. + // Legacy byte digests are never read back from checkout content. persist_authority(&parent, id, &authority) .map_err(|e| os_failure(id, "refresh_removal_authority", e))?; authority } } else { - let mut binding = materializer - .read_binding_for_cleanup(id) - .map_err(|_| cause("ownership_unknown"))?; - validate_binding(materializer, id, &binding)?; - // Preflight the entire tree without following links or crossing mounts, - // before asking Git to inspect the checkout. + // Preflight identities and entry metadata, never ordinary file bodies. let before = inventory(&tree).map_err(|e| os_failure(id, "inspect_tree", e))?; + let mut binding = read_cleanup_binding(materializer, id, &tree)?; + validate_binding(materializer, id, &binding)?; // Validated metadata-only residue contains no user content to discard. if !metadata_only(&before) { check_clean_checkout(id, &binding, &before, None)?; @@ -217,9 +238,9 @@ fn remove_with_checks( let raw = serde_json::to_vec_pretty(&record).map_err(|_| cause("storage_unavailable"))?; rewrite_record(&tree, &raw, before.get(Path::new(MATERIALIZATION_RECORD))) .map_err(|e| os_failure(id, "write_cleanup_pending", e))?; - let entries = inventory(&tree).map_err(|e| os_failure(id, "capture_removal_content", e))?; + let entries = inventory(&tree).map_err(|e| os_failure(id, "capture_entry_metadata", e))?; // Only our status-record write may differ between the clean observation - // and durable witness. Never authorize a newly arrived user change. + // and durable entry authority. Never authorize a newly arrived user change. let mut without_record = entries.clone(); without_record.remove(Path::new(MATERIALIZATION_RECORD)); let mut before_without_record = before; @@ -231,26 +252,22 @@ fn remove_with_checks( working_directory: binding.working_directory.clone(), root_identity, entries, + legacy: false, }; persist_authority(&parent, id, &authority) .map_err(|e| os_failure(id, "persist_removal_authority", e))?; authority }; - let current = inventory(&tree).map_err(|e| os_failure(id, "recheck_remaining_content", e))?; + let current = inventory(&tree).map_err(|e| os_failure(id, "recheck_remaining_metadata", e))?; verify_remaining(&authority.entries, ¤t)?; - // Repeat the content/identity comparison at each unlink. No unverified + check_live_root(&parent, id, authority.root_identity)?; + // Repeat entry metadata/identity comparison at each unlink. No unverified // recursion into a replaced entry, even if a mount arrives after preflight. erase_tree(&tree, Path::new(""), &authority.entries).map_err(|e| match e { EraseError::Io(e) => os_failure(id, "unlink_remaining_tree", e), EraseError::Changed => cause("changes_present"), })?; - let current_root = open_directory(&parent, std::ffi::OsStr::new(id)) - .map_err(|e| os_failure(id, "recheck_root_identity", e))?; - if identity(¤t_root).map_err(|e| os_failure(id, "recheck_root_identity", e))? - != authority.root_identity - { - return Err(cause("ownership_unknown")); - } + check_live_root(&parent, id, authority.root_identity)?; unlink(&parent, std::ffi::OsStr::new(id), true) .map_err(|e| os_failure(id, "unlink_cleanup_root", e))?; parent @@ -269,6 +286,57 @@ fn remove_with_checks( Ok(WorkingDirectoryStatus { summary }) } +#[cfg(target_os = "linux")] +fn check_live_root( + parent: &fs::File, + id: &str, + expected: (u64, u64), +) -> Result<(), WorkingDirectoryDiagnostic> { + let live = + open_directory(parent, std::ffi::OsStr::new(id)).map_err(|_| cause("ownership_unknown"))?; + if identity(&live).map_err(|e| os_failure(id, "recheck_root_identity", e))? != expected { + return Err(cause("ownership_unknown")); + } + Ok(()) +} + +#[cfg(target_os = "linux")] +fn read_cleanup_binding( + materializer: &RuntimeGitMaterializer, + id: &str, + tree: &fs::File, +) -> Result { + // This small, typed Runtime-owned record is distinct from checkout bodies. + // Descriptor-relative access also rejects symlink/mount substitution. + let raw = read_runtime_metadata( + tree, + std::ffi::OsStr::new(MATERIALIZATION_RECORD), + 1024 * 1024, + ) + .map_err(|e| { + if e.kind() == io::ErrorKind::InvalidData + || matches!(e.raw_os_error(), Some(libc::ELOOP | libc::ENOTDIR)) + { + cause("ownership_unknown") + } else { + os_failure(id, "read_cleanup_record", e) + } + })?; + let record: WorkingDirectoryMaterializationRecord = + serde_json::from_slice(&raw).map_err(|_| cause("ownership_unknown"))?; + Ok(WorkingDirectoryBinding { + working_directory: record.working_directory, + root: record.root.clone(), + cwd: record.root, + working_directory_root: materializer.working_directory_root(id), + command_environment: BTreeMap::new(), + session_resources: Vec::new(), + occupancy: materializer.occupancy.clone(), + occupancy_id: id.to_string(), + generation: materializer.occupancy.generation(&id), + }) +} + #[cfg(target_os = "linux")] fn metadata_only(entries: &BTreeMap) -> bool { entries.iter().all(|(path, entry)| { @@ -299,21 +367,36 @@ fn retry_inventory( saved: &Authority, ) -> Result, WorkingDirectoryDiagnostic> { let before = inventory(tree).map_err(|e| os_failure(id, "inspect_retry_tree", e))?; - if verify_remaining(&saved.entries, &before).is_ok() { - // With no Git left, only the old witness authorizes the surviving subset. + // A surviving Runtime record must still describe the same materialization, + // even when its entry metadata matches. Access proof refresh is allowed. + for required in [Path::new(CHECKOUT_DIR), Path::new("checkout/.git")] { + if let Some(entry) = before.get(required) { + if saved.entries.get(required) != Some(entry) { + return Err(cause("ownership_unknown")); + } + } + } + let binding = if before.contains_key(Path::new(MATERIALIZATION_RECORD)) { + let binding = read_cleanup_binding(materializer, id, tree)?; + validate_binding(materializer, id, &binding)?; + if !same_materialization(&saved.working_directory, &binding.working_directory) { + return Err(cause("ownership_unknown")); + } + Some(binding) + } else { + None + }; + if !saved.legacy && verify_remaining(&saved.entries, &before).is_ok() { + // Missing entries are expected after partial unlink. Only the matching + // surviving subset is admitted, even without a usable Git checkout. return Ok(before); } - let binding = materializer - .read_binding_for_cleanup(id) - .map_err(|_| cause("changes_present"))?; - validate_binding(materializer, id, &binding)?; - if !same_materialization(&saved.working_directory, &binding.working_directory) - || before - .get(Path::new(MATERIALIZATION_RECORD)) - .is_none_or(|entry| entry.mode & libc::S_IFMT != libc::S_IFREG) - { - return Err(cause("ownership_unknown")); + if metadata_only(&before) { + // Even a legacy root with no entries left has no user content. Root + // identity and saved ownership are still required; validate any record. + return Ok(before); } + let binding = binding.ok_or_else(|| cause("changes_unknown"))?; check_clean_checkout(id, &binding, &before, Some(saved))?; let parent = fs::File::open(&materializer.runtime_root) .map_err(|e| os_failure(id, "reopen_runtime_root", e))?; @@ -324,7 +407,7 @@ fn retry_inventory( { return Err(cause("ownership_unknown")); } - if inventory(tree).map_err(|e| os_failure(id, "recheck_retry_content", e))? != before { + if inventory(tree).map_err(|e| os_failure(id, "recheck_retry_metadata", e))? != before { return Err(cause("changes_present")); } Ok(before) @@ -351,6 +434,8 @@ fn check_clean_checkout( } } let run = |args: &[&str]| -> Result, WorkingDirectoryDiagnostic> { + #[cfg(test)] + io_observation::record(io_observation::Event::GitInspection); let output = isolated_git_command() .env("GIT_OPTIONAL_LOCKS", "0") // Do not run repository-configured fsmonitor hooks while Runtime @@ -387,14 +472,20 @@ fn check_clean_checkout( } } // Git clean is not ignored-content permission. Current tracked content - // and Git metadata may change; all other entries stay witness-protected. + // and Git metadata may change; all other entries stay metadata-protected. for (path, entry) in before { if path != Path::new(MATERIALIZATION_RECORD) && !path.starts_with("checkout/.git") && !git_paths.contains(path) - && saved.entries.get(path) != Some(entry) { - return Err(cause("changes_present")); + // Old digests cannot establish current ignored/outside-tree + // safety without body reads. Never renew uncertain residue. + if saved.legacy { + return Err(cause("changes_unknown")); + } + if saved.entries.get(path) != Some(entry) { + return Err(cause("changes_present")); + } } } } @@ -459,7 +550,17 @@ pub(super) fn validate_binding( id: &str, binding: &WorkingDirectoryBinding, ) -> Result<(), WorkingDirectoryDiagnostic> { - let directory = &binding.working_directory; + validate_directory_owner(id, &binding.working_directory)?; + if binding.root != materializer.working_directory_root(id).join(CHECKOUT_DIR) { + return Err(cause("ownership_unknown")); + } + Ok(()) +} + +fn validate_directory_owner( + id: &str, + directory: &WorkingDirectory, +) -> Result<(), WorkingDirectoryDiagnostic> { if directory.id != id || directory.materializer_kind != MaterializerKind::RuntimeGitClone || directory.evidence.materializer_kind != MaterializerKind::RuntimeGitClone @@ -467,7 +568,6 @@ pub(super) fn validate_binding( || directory.cleanup_target.working_directory_id != id || directory.cleanup_target.repository_id != directory.repository_id || directory.cleanup_target.kind != "runtime_git_clone" - || binding.root != materializer.working_directory_root(id).join(CHECKOUT_DIR) { return Err(cause("ownership_unknown")); } @@ -479,7 +579,60 @@ pub(super) fn validate_binding( struct Authority { working_directory: WorkingDirectory, root_identity: (u64, u64), + #[serde(rename = "entry_metadata")] entries: BTreeMap, + #[serde(skip)] + legacy: bool, +} + +// Isolated reader for the old persisted shape. Serde discards its digest field; +// we never recompute or compare it. Lack of metadata cannot authorize uncertain +// partial or ignored residue. +#[cfg(target_os = "linux")] +#[derive(Deserialize)] +struct LegacyAuthority { + working_directory: WorkingDirectory, + root_identity: (u64, u64), + entries: BTreeMap, +} + +#[cfg(target_os = "linux")] +#[derive(Deserialize)] +struct LegacyEntry { + identity: (u64, u64), + mode: u32, +} + +#[cfg(target_os = "linux")] +fn decode_authority(raw: &[u8]) -> io::Result { + #[derive(Deserialize)] + #[serde(untagged)] + enum Stored { + Metadata(Authority), + Legacy(LegacyAuthority), + } + match serde_json::from_slice(raw).map_err(|_| io::Error::from(io::ErrorKind::InvalidData))? { + Stored::Metadata(authority) => Ok(authority), + Stored::Legacy(saved) => Ok(Authority { + working_directory: saved.working_directory, + root_identity: saved.root_identity, + entries: saved + .entries + .into_iter() + .map(|(path, entry)| { + ( + path, + Entry { + identity: entry.identity, + mode: entry.mode, + file_change: None, + }, + ) + }) + .collect(), + legacy: true, + }), + } } #[cfg(target_os = "linux")] @@ -487,7 +640,16 @@ struct Authority { struct Entry { identity: (u64, u64), mode: u32, - digest: Option, + // None for directories: removing children legitimately changes timestamps. + file_change: Option, +} + +#[cfg(target_os = "linux")] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +struct FileChange { + size: u64, + modified_seconds: i64, + modified_nanoseconds: i64, } #[cfg(target_os = "linux")] @@ -561,7 +723,7 @@ fn bounded_authority_bytes(authority: &Authority, limit: usize) -> io::Result limit { return Err(io::Error::new( io::ErrorKind::InvalidData, - "removal content authority exceeds its readable size limit", + "removal entry authority exceeds its readable size limit", )); } Ok(raw) @@ -583,6 +745,20 @@ mod linux { } fn open(parent: &fs::File, name: &OsStr, flags: i32) -> io::Result { + let file = open_inner(parent, name, flags)?; + // Audit the actual access mode at the descriptor boundary. O_PATH stat + // cannot read a body; directory reads enumerate names. Runtime metadata + // uses its separate bounded reader below, Git runs outside this walker. + #[cfg(test)] + if flags & (libc::O_PATH | libc::O_DIRECTORY | libc::O_WRONLY) == 0 + && file.metadata()?.is_file() + { + io_observation::record(io_observation::Event::EntryReadableOpen); + } + Ok(file) + } + + fn open_inner(parent: &fs::File, name: &OsStr, flags: i32) -> io::Result { let name = CString::new(name.as_bytes()) .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; // NO_XDEV catches bind mounts too (st_dev comparisons do not). @@ -647,21 +823,12 @@ mod linux { pub(super) fn load_authority(parent: &fs::File, id: &str) -> io::Result> { let read = || -> io::Result { let directory = authority_directory(parent, false)?; - let file = open( + let raw = read_runtime_metadata( &directory, OsStr::new(&format!("{id}.json")), - libc::O_RDONLY, + MAX_AUTHORITY_BYTES, )?; - if !file.metadata()?.is_file() || file.metadata()?.len() > MAX_AUTHORITY_BYTES as u64 { - return Err(io::Error::from(io::ErrorKind::InvalidData)); - } - let mut raw = Vec::new(); - file.take(MAX_AUTHORITY_BYTES as u64 + 1) - .read_to_end(&mut raw)?; - if raw.len() > MAX_AUTHORITY_BYTES { - return Err(io::Error::from(io::ErrorKind::InvalidData)); - } - serde_json::from_slice(&raw).map_err(|_| io::Error::from(io::ErrorKind::InvalidData)) + decode_authority(&raw) }; match read() { Ok(authority) => Ok(Some(authority)), @@ -670,12 +837,31 @@ mod linux { } } + pub(super) fn read_runtime_metadata( + dir: &fs::File, + name: &OsStr, + limit: usize, + ) -> io::Result> { + let file = open_inner(dir, name, libc::O_RDONLY)?; + if !file.metadata()?.is_file() || file.metadata()?.len() > limit as u64 { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + let mut raw = Vec::new(); + file.take(limit as u64 + 1).read_to_end(&mut raw)?; + #[cfg(test)] + io_observation::record(io_observation::Event::RuntimeMetadataRead(raw.len())); + if raw.len() > limit { + return Err(io::Error::from(io::ErrorKind::InvalidData)); + } + Ok(raw) + } + pub(super) fn persist_authority( parent: &fs::File, id: &str, authority: &Authority, ) -> io::Result<()> { - // Never publish a witness that the next normal retry cannot read. + // Never publish entry authority that the next normal retry cannot read. let raw = bounded_authority_bytes(authority, MAX_AUTHORITY_BYTES)?; let directory = authority_directory(parent, true)?; let temporary = CString::new(format!(".{id}-{}.tmp", uuid::Uuid::now_v7())).unwrap(); @@ -726,55 +912,13 @@ mod linux { } pub(super) fn inspect(dir: &fs::File, name: &OsStr) -> io::Result { + #[cfg(test)] + io_observation::record(io_observation::Event::EntryInspection); let file = open(dir, name, libc::O_PATH)?; let metadata = file.metadata()?; - let digest = if metadata.is_file() { - let mut reader = open(dir, name, libc::O_RDONLY)?; - if identity(&reader)? != (metadata.dev(), metadata.ino()) { - return Err(io::Error::from_raw_os_error(libc::EAGAIN)); - } - let mut hash = Sha256::new(); - let mut buffer = [0u8; 65536]; - loop { - let count = reader.read(&mut buffer)?; - if count == 0 { - break; - } - hash.update(&buffer[..count]); - } - Some( - hash.finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(), - ) - } else if metadata.file_type().is_symlink() { - let mut buffer = vec![0u8; 65536]; - // SAFETY: valid O_PATH symlink descriptor; empty path reads that - // symlink, not its target. The mutable buffer has the stated length. - let count = unsafe { - libc::readlinkat( - file.as_raw_fd(), - c"".as_ptr(), - buffer.as_mut_ptr().cast(), - buffer.len(), - ) - }; - if count < 0 { - return Err(io::Error::last_os_error()); - } - buffer.truncate(count as usize); - Some( - Sha256::digest(&buffer) - .iter() - .map(|byte| format!("{byte:02x}")) - .collect(), - ) - } else if metadata.is_dir() { - None - } else { + if !metadata.is_file() && !metadata.is_dir() && !metadata.file_type().is_symlink() { return Err(io::Error::from_raw_os_error(libc::EBUSY)); - }; + } Ok(Entry { identity: (metadata.dev(), metadata.ino()), // Permission repair is not a content change. Preserve the file type @@ -785,7 +929,11 @@ mod linux { } else { 0 }, - digest, + file_change: (!metadata.is_dir()).then(|| FileChange { + size: metadata.len(), + modified_seconds: metadata.mtime(), + modified_nanoseconds: metadata.mtime_nsec(), + }), }) } @@ -804,7 +952,11 @@ mod linux { let path = path.join(&name); entries.insert(path.clone(), entry); if is_dir { - walk(&open_directory(dir, &name)?, &path, entries)?; + let directory = open_directory(dir, &name)?; + if identity(&directory)? != entries[&path].identity { + return Err(io::Error::from_raw_os_error(libc::EAGAIN)); + } + walk(&directory, &path, entries)?; } } Ok(()) @@ -833,6 +985,8 @@ mod linux { } pub(super) fn unlink(dir: &fs::File, name: &OsStr, directory: bool) -> io::Result<()> { + #[cfg(test)] + io_observation::before_unlink()?; let name = CString::new(name.as_bytes()) .map_err(|_| io::Error::from_raw_os_error(libc::EINVAL))?; // SAFETY: valid descriptor and NUL-terminated single entry name. @@ -924,6 +1078,74 @@ mod linux { } } +// Scoped instrumentation of production open/read/Git/unlink boundaries. +// Thread-local state keeps parallel fixture tests independent. +#[cfg(all(test, target_os = "linux"))] +mod io_observation { + use super::*; + use std::cell::RefCell; + #[derive(Debug, Default)] + pub(super) struct Audit { + pub entry_readable_opens: usize, + pub runtime_metadata_bytes: usize, + pub git_inspections: usize, + pub entry_inspections: usize, + pub unlinks: usize, + fail_after: Option, + } + pub(super) enum Event { + EntryReadableOpen, + RuntimeMetadataRead(usize), + GitInspection, + EntryInspection, + } + thread_local! { + static ACTIVE: RefCell> = const { RefCell::new(None) }; + } + pub(super) fn record(event: Event) { + ACTIVE.with_borrow_mut(|active| { + if let Some(audit) = active { + match event { + Event::EntryReadableOpen => audit.entry_readable_opens += 1, + Event::RuntimeMetadataRead(bytes) => audit.runtime_metadata_bytes += bytes, + Event::GitInspection => audit.git_inspections += 1, + Event::EntryInspection => audit.entry_inspections += 1, + } + } + }); + } + pub(super) fn before_unlink() -> io::Result<()> { + ACTIVE.with_borrow_mut(|active| { + if let Some(audit) = active { + if audit.fail_after == Some(audit.unlinks) { + return Err(io::Error::from_raw_os_error(libc::EACCES)); + } + audit.unlinks += 1; + } + Ok(()) + }) + } + pub(super) fn observe(fail_after: Option, action: impl FnOnce() -> T) -> (T, Audit) { + struct Reset; + impl Drop for Reset { + fn drop(&mut self) { + ACTIVE.with_borrow_mut(|active| *active = None); + } + } + ACTIVE.with_borrow_mut(|active| { + assert!(active.is_none(), "nested cleanup I/O audit"); + *active = Some(Audit { + fail_after, + ..Audit::default() + }); + }); + let _reset = Reset; + let result = action(); + let audit = ACTIVE.with_borrow_mut(|active| active.take().unwrap()); + (result, audit) + } +} + #[cfg(all(test, target_os = "linux"))] pub(super) mod tests { use super::super::tests::{create_clean_repo, request}; @@ -949,13 +1171,423 @@ pub(super) mod tests { assert_eq!(error.code, "working_directory_cleanup_permission_denied"); } + struct BodyWatch(fs::File); + impl BodyWatch { + fn new(paths: &[PathBuf]) -> Self { + use std::os::fd::{AsRawFd, FromRawFd}; + use std::os::unix::ffi::OsStrExt; + // SAFETY: inotify_init1 returns a new descriptor; no pointer args. + let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; + assert!(fd >= 0, "{}", io::Error::last_os_error()); + // SAFETY: freshly owned fd, transferred once to File. + let events = unsafe { fs::File::from_raw_fd(fd) }; + for path in paths { + let name = std::ffi::CString::new(path.as_os_str().as_bytes()).unwrap(); + // SAFETY: live descriptor and NUL-terminated fixture path. + let watch = unsafe { + libc::inotify_add_watch( + events.as_raw_fd(), + name.as_ptr(), + libc::IN_OPEN | libc::IN_ACCESS, + ) + }; + assert!(watch >= 0, "{}", io::Error::last_os_error()); + } + Self(events) + } + fn assert_no_body_access(&mut self) { + let mut buffer = [0u8; 8192]; + loop { + let count = match self.0.read(&mut buffer) { + Ok(count) => count, + Err(e) if e.kind() == io::ErrorKind::WouldBlock => break, + other => panic!("inotify read: {other:?}"), + }; + assert!(count > 0); + let mut offset = 0; + while offset < count { + let mask = + u32::from_ne_bytes(buffer[offset + 4..offset + 8].try_into().unwrap()); + let len = + u32::from_ne_bytes(buffer[offset + 12..offset + 16].try_into().unwrap()) + as usize; + assert_eq!( + mask & (libc::IN_OPEN | libc::IN_ACCESS | libc::IN_Q_OVERFLOW), + 0, + "ordinary fixture body accessed: {mask:#x}" + ); + offset += 16 + len; + } + } + } + } + + fn sized_fixture( + size: u64, + ) -> ( + tempfile::TempDir, + RuntimeGitMaterializer, + WorkingDirectoryBinding, + BodyWatch, + ) { + let (runtime, materializer, binding) = fixture(); + fs::write(binding.root.join(".git/info/exclude"), "target/\n").unwrap(); + fs::create_dir(binding.root.join("target")).unwrap(); + // Entry count remains constant, including .git and many ignored files. + let mut watched = Vec::new(); + for i in 0..96 { + let path = binding.root.join(format!("target/result-{i}")); + fs::write(&path, b"ignored build output").unwrap(); + watched.push(path); + } + for path in [ + binding.root.join("target/large-artifact"), + binding.root.join(".git/objects/unused-cleanup-artifact"), + ] { + let mut file = fs::File::create(&path).unwrap(); + file.write_all(b"payload").unwrap(); + file.set_len(size).unwrap(); // Owned sparse files, no expensive allocation. + watched.push(path); + } + let watch = BodyWatch::new(&watched); + (runtime, materializer, binding, watch) + } + #[test] - fn persisted_removal_witness_must_fit_the_same_readable_limit() { + fn actual_cleanup_and_restart_retry_never_open_bodies_when_only_file_sizes_grow() { + use io_observation::observe; + for failure in [None, Some(0), Some(10)] { + let mut small_counts = None; + for size in [256, 128 * 1024 * 1024] { + let (runtime, materializer, binding, mut watch) = sized_fixture(size); + let id = &binding.working_directory.id; + let (first, first_io) = + observe(failure, || materializer.cleanup_working_directory(id)); + assert_eq!(first_io.entry_readable_opens, 0, "{first_io:?}"); + assert!( + first_io.git_inspections >= 2, + "Git inspection is a separate necessary operation" + ); + assert!( + first_io.runtime_metadata_bytes > 0 + && first_io.runtime_metadata_bytes < 1024 * 1024 + ); + assert!(first_io.entry_inspections > 100); + watch.assert_no_body_access(); + let retry_io = if let Some(unlinked) = failure { + assert_eq!( + first.unwrap_err().code, + "working_directory_cleanup_permission_denied" + ); + assert_eq!(first_io.unlinks, unlinked); + assert!(binding.working_directory_root.exists()); + let raw: serde_json::Value = serde_json::from_slice( + &fs::read( + runtime + .path() + .join(".cleanup-authority") + .join(format!("{id}.json")), + ) + .unwrap(), + ) + .unwrap(); + assert!(raw.get("entries").is_none()); + for entry in raw["entry_metadata"].as_object().unwrap().values() { + assert!(entry.get("digest").is_none()); + assert!(entry.get("file_change").is_some()); + } + drop(materializer); + let restarted = RuntimeGitMaterializer::new(runtime.path()); + let (removed, io) = observe(None, || restarted.cleanup_working_directory(id)); + assert_eq!( + removed.unwrap().summary.status, + WorkingDirectoryStatusKind::NotFound + ); + assert_eq!(io.entry_readable_opens, 0, "{io:?}"); + assert!( + io.runtime_metadata_bytes > 0 && io.runtime_metadata_bytes < 1024 * 1024 + ); + assert!(io.unlinks > 0 && io.entry_inspections > 0); + watch.assert_no_body_access(); + Some(io) + } else { + assert_eq!( + first.unwrap().summary.status, + WorkingDirectoryStatusKind::NotFound + ); + assert!(first_io.unlinks > 100); + None + }; + eprintln!( + "cleanup size={size} fail_after={failure:?}: first={first_io:?}, retry={retry_io:?}" + ); + assert!(!binding.working_directory_root.exists()); + assert!( + !runtime + .path() + .join(".cleanup-authority") + .join(format!("{id}.json")) + .exists() + ); + let counts = ( + first_io.entry_inspections, + first_io.unlinks, + retry_io + .as_ref() + .map(|io| (io.entry_inspections, io.unlinks)), + ); + if let Some(small) = small_counts { + assert_eq!( + counts, small, + "size growth cannot add body I/O or walker work" + ); + } else { + small_counts = Some(counts); + } + } + } + } + + fn seed_legacy_authority( + runtime: &Path, + materializer: &RuntimeGitMaterializer, + binding: &WorkingDirectoryBinding, + ) { + let mut binding = binding.clone(); + binding.working_directory.status = WorkingDirectoryStatusKind::CleanupPending; + materializer.write_record(&binding).unwrap(); + let tree = fs::File::open(&binding.working_directory_root).unwrap(); + let entries: BTreeMap<_, _> = inventory(&tree).unwrap().into_iter().map(|(path, entry)| (path, serde_json::json!({"identity": entry.identity, "mode": entry.mode, "digest": "obsolete-not-recomputed"}))).collect(); + let raw = serde_json::to_vec(&serde_json::json!({"working_directory": binding.working_directory, "root_identity": identity(&tree).unwrap(), "entries": entries})).unwrap(); + let private = runtime.join(".cleanup-authority"); + fs::create_dir(&private).unwrap(); + fs::write( + private.join(format!("{}.json", binding.working_directory.id)), + raw, + ) + .unwrap(); + } + + #[test] + fn legacy_migration_persists_entry_metadata_before_partial_unlink_and_restart() { + use io_observation::observe; + let (runtime, materializer, binding) = fixture(); + let payload = binding.root.join(".git/objects/unused-cleanup-artifact"); + fs::File::create(&payload) + .unwrap() + .set_len(128 * 1024 * 1024) + .unwrap(); + let mut watch = BodyWatch::new(&[payload]); + seed_legacy_authority(runtime.path(), &materializer, &binding); + let id = &binding.working_directory.id; + let (failed, first_io) = observe(Some(10), || materializer.cleanup_working_directory(id)); + assert_eq!( + failed.unwrap_err().code, + "working_directory_cleanup_permission_denied" + ); + assert_eq!(first_io.entry_readable_opens, 0); + assert_eq!(first_io.unlinks, 10); + assert!(first_io.git_inspections >= 3); + watch.assert_no_body_access(); + let raw: serde_json::Value = serde_json::from_slice( + &fs::read( + runtime + .path() + .join(".cleanup-authority") + .join(format!("{id}.json")), + ) + .unwrap(), + ) + .unwrap(); + assert!(raw.get("entries").is_none()); + assert!(raw["entry_metadata"].is_object()); + drop(materializer); + let restarted = RuntimeGitMaterializer::new(runtime.path()); + let (removed, retry_io) = observe(None, || restarted.cleanup_working_directory(id)); + assert_eq!( + removed.unwrap().summary.status, + WorkingDirectoryStatusKind::NotFound + ); + assert_eq!(retry_io.entry_readable_opens, 0); + assert_eq!(retry_io.git_inspections, 0); + assert!(retry_io.unlinks > 0); + watch.assert_no_body_access(); + assert!(!binding.working_directory_root.exists()); + } + + #[test] + fn legacy_authority_normal_retry_migrates_clean_checkout_or_explains_uncertain_residue() { + use io_observation::observe; + for remaining in [ + "clean", + "dirty", + "ignored", + "partial", + "metadata_only", + "empty", + "replacement", + ] { + let (runtime, materializer, binding) = fixture(); + if remaining == "ignored" { + fs::write(binding.root.join(".git/info/exclude"), "output\n").unwrap(); + fs::write(binding.root.join("output"), "legacy ignored residue").unwrap(); + } + seed_legacy_authority(runtime.path(), &materializer, &binding); + match remaining { + "partial" => fs::remove_dir_all(binding.root.join(".git")).unwrap(), + "metadata_only" => fs::remove_dir_all(binding.root()).unwrap(), + "empty" => { + fs::remove_dir_all(binding.root()).unwrap(); + fs::remove_file(binding.working_directory_root.join(MATERIALIZATION_RECORD)) + .unwrap(); + } + "dirty" => fs::write(binding.root.join("README.md"), "user change").unwrap(), + "replacement" => { + fs::rename( + &binding.working_directory_root, + runtime.path().join("original"), + ) + .unwrap(); + fs::create_dir(&binding.working_directory_root).unwrap(); + } + _ => {} + } + drop(materializer); + let restarted = RuntimeGitMaterializer::new(runtime.path()); + let (result, io) = observe(None, || { + restarted.cleanup_working_directory(&binding.working_directory.id) + }); + assert_eq!(io.entry_readable_opens, 0, "{remaining}: {io:?}"); + match remaining { + "clean" | "metadata_only" | "empty" => { + assert_eq!( + result.unwrap().summary.status, + WorkingDirectoryStatusKind::NotFound + ); + assert!(!binding.working_directory_root.exists()); + } + _ => { + assert_eq!( + result.unwrap_err().code, + format!( + "working_directory_cleanup_{}", + match remaining { + "dirty" => "changes_present", + "replacement" => "ownership_unknown", + _ => "changes_unknown", + } + ), + "{remaining}" + ); + assert_eq!(io.unlinks, 0); + assert!(binding.working_directory_root.exists()); + } + } + } + } + + #[test] + fn partial_metadata_mismatch_without_git_preserves_uncertain_residue() { + for change in ["new", "same_size_mtime", "replaced"] { + let (runtime, materializer, binding) = fixture(); + fail_once(&materializer, &binding.working_directory.id); + fs::remove_dir_all(binding.root.join(".git")).unwrap(); + match change { + "new" => fs::write(binding.root.join("new"), "new").unwrap(), + "same_size_mtime" => { + use std::os::fd::AsRawFd; + let mut file = fs::OpenOptions::new() + .write(true) + .open(binding.root.join("README.md")) + .unwrap(); + file.write_all(b"x").unwrap(); + // Explicit metadata change, deterministic even on coarse clocks. + let times = [libc::timespec { + tv_sec: 1, + tv_nsec: 0, + }; 2]; + // SAFETY: live fixture fd and two valid timespec values. + assert_eq!( + unsafe { libc::futimens(file.as_raw_fd(), times.as_ptr()) }, + 0 + ); + } + _ => { + fs::rename( + binding.root.join("README.md"), + runtime.path().join("old-file"), + ) + .unwrap(); + fs::write(binding.root.join("README.md"), "replacement").unwrap(); + } + } + let restarted = RuntimeGitMaterializer::new(runtime.path()); + assert_eq!( + restarted + .cleanup_working_directory(&binding.working_directory.id) + .unwrap_err() + .code, + "working_directory_cleanup_changes_unknown", + "{change}" + ); + assert!(binding.root.join("README.md").exists()); + } + } + + #[test] + fn unlink_rechecks_metadata_and_rejects_arriving_or_replaced_entries() { + for change in ["new", "same_size", "replacement", "symlink"] { + let (runtime, materializer, binding) = fixture(); + fs::write(binding.root.join(".git/info/exclude"), "output\n").unwrap(); + let file = binding.root.join("output"); + fs::write(&file, b"initial").unwrap(); + let error = remove_with_eraser( + &materializer, + &binding.working_directory.id, + |dir, path, expected| { + match change { + "new" => fs::write(binding.root.join("new"), b"new").unwrap(), + "same_size" => { + use std::os::fd::AsRawFd; + let mut f = fs::OpenOptions::new().write(true).open(&file).unwrap(); + f.write_all(b"changed").unwrap(); + let times = [libc::timespec { + tv_sec: 1, + tv_nsec: 0, + }; 2]; + // SAFETY: live fixture fd and two valid timespec values. + assert_eq!(unsafe { libc::futimens(f.as_raw_fd(), times.as_ptr()) }, 0); + } + "replacement" => { + fs::rename(&file, runtime.path().join("old-output")).unwrap(); + fs::write(&file, b"initial").unwrap(); + } + _ => { + fs::remove_file(&file).unwrap(); + std::os::unix::fs::symlink(runtime.path(), &file).unwrap(); + } + } + erase(dir, path, expected) + }, + ) + .unwrap_err(); + assert_eq!( + error.code, "working_directory_cleanup_changes_present", + "{change}" + ); + assert!(binding.working_directory_root.exists()); + assert!(file.symlink_metadata().is_ok()); + } + } + + #[test] + fn persisted_entry_authority_must_fit_the_same_readable_limit() { let (runtime, _, binding) = fixture(); let authority = Authority { working_directory: binding.working_directory.clone(), root_identity: (0, 0), entries: BTreeMap::new(), + legacy: false, }; let bytes = bounded_authority_bytes(&authority, MAX_AUTHORITY_BYTES).unwrap(); assert_eq!( @@ -997,7 +1629,7 @@ pub(super) mod tests { fail_once(&materializer, id); // Observe actual file opens rather than a timing threshold or a large - // fixture. Inventory would open this surviving tracked file to hash it. + // fixture. A content-scanning regression would open this surviving tracked file. // SAFETY: inotify_init1 has no pointer arguments. let fd = unsafe { libc::inotify_init1(libc::IN_NONBLOCK | libc::IN_CLOEXEC) }; assert!(fd >= 0, "{}", io::Error::last_os_error()); @@ -1030,7 +1662,7 @@ pub(super) mod tests { } #[test] - fn partial_deletion_missing_checkout_and_restart_finish_with_durable_content_authority() { + fn partial_deletion_missing_checkout_and_restart_finish_with_durable_entry_authority() { for removed in [ "checkout/README.md", "checkout/.git", @@ -1077,7 +1709,7 @@ pub(super) mod tests { } #[test] - fn missing_checkout_with_only_validated_metadata_can_be_removed_without_witness() { + fn missing_checkout_with_only_validated_metadata_can_be_removed_without_entry_authority() { let (_runtime, materializer, binding) = fixture(); fs::remove_dir_all(binding.root()).unwrap(); let removed = materializer @@ -1088,7 +1720,7 @@ pub(super) mod tests { } #[test] - fn incomplete_checkout_without_witness_preserves_residue_and_explains_recovery() { + fn incomplete_checkout_without_entry_authority_preserves_residue_and_explains_recovery() { let (_runtime, materializer, binding) = fixture(); fs::remove_dir_all(binding.root.join(".git")).unwrap(); let error = materializer @@ -1100,7 +1732,7 @@ pub(super) mod tests { } #[test] - fn failure_then_dirty_then_owner_resolution_renews_witness_on_ordinary_retry() { + fn failure_then_dirty_then_owner_resolution_renews_entry_authority_on_ordinary_retry() { use super::super::tests::git; for resolution in ["commit", "restore"] { let (runtime, materializer, binding) = fixture(); @@ -1160,7 +1792,7 @@ pub(super) mod tests { Some("unknown"), "{resolution}" ); - // Renewal itself can fail at unlink. Its new witness must survive a + // Renewal itself can fail at unlink. Its new entry authority must survive a // later partial deletion/restart, even with no remaining Git. fail_once(&restarted, id); fs::remove_dir_all(binding.root.join(".git")).unwrap(); @@ -1213,11 +1845,11 @@ pub(super) mod tests { } #[test] - fn pending_witness_accepts_access_refresh_but_rejects_materialization_substitution() { + fn pending_entry_authority_accepts_access_refresh_but_rejects_materialization_substitution() { for update in ["access", "repository", "creation"] { let (_runtime, materializer, binding) = fixture(); let id = &binding.working_directory.id; - // Model an existing SSH materialization before taking the witness. + // Model an existing SSH materialization before capturing entry metadata. // Refresh replaces its access proof, not its requirement for SSH authority. let mut record = materializer.read_binding(id).unwrap(); record.working_directory.evidence.repository_access_required = true; diff --git a/crates/worker/src/compact/worker.rs b/crates/worker/src/compact/worker.rs index 96dbc2ffd..0480d091e 100644 --- a/crates/worker/src/compact/worker.rs +++ b/crates/worker/src/compact/worker.rs @@ -1,9 +1,22 @@ //! Summarization and reference-file selection by the compaction Internal Worker. //! -//! Summary input is a bounded history projection; it does not rewrite the original history. +//! Ordinary history uses a bounded overview plus exploration, while the previous Host-produced +//! `CompactionSummary` is mandatory full-text input independent of overview and retention budgets. +//! The replacement supersedes that old summary; it is excluded from both ordinary overview and +//! retained tail. Other derived context remains subject to normal retention. Current tasks and +//! recent progress support continuity, but do not replace original goals or completion conditions. +//! +//! The full inherited summary still counts against the hard compactor input cap. Before provider +//! measurements exist, occupancy includes input, system prompt, and installed tools. The normal +//! Worker interceptor enforces this cap, including persisted budget warnings; overflow fails +//! without activating a replacement rather than clipping mandatory material. Provider failures +//! likewise leave the source active. Estimates are not exact model-token guarantees. +//! //! Results collect in [`CompactWorkerContext`]; [`crate::Worker::compact`] validates, saves, -//! and activates them. Missing summaries or hard-limit violations fail compaction, unlike -//! targets or warning-only thresholds. +//! and activates them. Missing summaries and hard input/output limits fail compaction. An oversized +//! output gets a bounded rewrite attempt. The assembled replacement's result-context threshold is +//! advisory: a warning neither drops content nor bypasses subsequent request-time safety checks. +//! Deterministic transport/retention tests do not prove a model's semantic retention of user intent. //! //! Auto-read accepts only UTF-8 text through the current WorkdirSession, checking scope and //! the aggregate budget. Images, binary data, and truncated content stay out of the body; @@ -372,6 +385,7 @@ pub(crate) struct CompactWorkerInterceptor { pub on_warning: Option>, warning_sent: AtomicBool, last_remaining_tokens: AtomicU64, + request_shape: Option<(String, agen::tool_server::ToolServerHandle)>, } impl CompactWorkerInterceptor { @@ -390,9 +404,21 @@ impl CompactWorkerInterceptor { on_warning, warning_sent: AtomicBool::new(false), last_remaining_tokens: AtomicU64::new(max_input_tokens), + request_shape: None, } } + pub(crate) fn with_request_shape( + mut self, + system_prompt: String, + tools: agen::tool_server::ToolServerHandle, + ) -> Self { + // Features install after engine configuration. This shared handle sees + // the final installed tool definitions at the actual request boundary. + self.request_shape = Some((system_prompt, tools)); + self + } + fn maybe_emit_warning(&self, remaining: u64) -> Option { let warning_threshold = self.finish_warning_remaining_tokens; let reserve_threshold = self.final_reserve_tokens; @@ -405,7 +431,12 @@ impl CompactWorkerInterceptor { let message = format!( "compact worker context budget is low ({remaining}/{} tokens remaining). \ Stop broad exploration now, read only if absolutely necessary, then call \ - `write_summary` with the final structured summary.", + `write_summary` with the final structured summary. Preserve ongoing goals and \ + user directives from the full initial summary and segment. Compress duplicate \ + explanations and old work details first, not scope, prohibitions, completion \ + conditions or exception-reporting duties. Apply explicit later changes/retractions \ + and retain uncertainty; recovery procedures and recent progress do not replace \ + the original request.", self.max_input_tokens ); if let Some(cb) = self.on_warning.as_ref() { @@ -426,17 +457,38 @@ impl Interceptor for CompactWorkerInterceptor { let context = context.items; let records = self.usage_tracker.records(); let estimate = agen::token_counter::total_tokens(context, &records); - if estimate.tokens > self.max_input_tokens { + // Before the first provider measurement, include the system and tool + // shape as well as *all* mandatory summary/input bytes. Later exact + // input measurements already include that shape; do not double charge. + let shape_tokens = if records.is_empty() { + self.request_shape.as_ref().map_or(0, |(system, tools)| { + let tool_bytes = serde_json::to_vec(&tools.tool_definitions_sorted()) + .map(|bytes| bytes.len()) + .unwrap_or(0); + estimate_tokens(system.len().saturating_add(tool_bytes)) + }) + } else { + 0 + }; + let input_tokens = estimate.tokens.saturating_add(shape_tokens); + if input_tokens > self.max_input_tokens { return Ok(PreRequestAction::Cancel(format!( - "compact worker input occupancy exceeded {} tokens", + "compact worker input occupancy (including mandatory full initial summary) exceeded {} tokens; no summary was truncated", self.max_input_tokens ))); } - let remaining = self.max_input_tokens.saturating_sub(estimate.tokens); + let remaining = self.max_input_tokens.saturating_sub(input_tokens); self.last_remaining_tokens .store(remaining, Ordering::Release); if let Some(item) = self.maybe_emit_warning(remaining) { + let warning_tokens = + agen::token_counter::total_tokens(std::slice::from_ref(&item), &[]).tokens; + if input_tokens.saturating_add(warning_tokens) > self.max_input_tokens { + return Ok(PreRequestAction::Cancel( + "compact worker input occupancy cannot fit budget warning alongside mandatory full input; no summary was truncated".into(), + )); + } self.usage_tracker.note_request(context.len() + 1); return Ok(PreRequestAction::ContinueWith(vec![item])); } @@ -535,6 +587,85 @@ mod tests { )); } + #[tokio::test] + async fn compact_worker_warning_policy_keeps_user_intent_and_fails_if_warning_cannot_fit() { + let interceptor = + CompactWorkerInterceptor::new(Arc::new(UsageTracker::new()), 100, 100, 0, None); + let mut context = vec![Item::user_message("full input")]; + let result = interceptor + .pre_llm_request(PreLlmRequestContext::<()> { + invocation: Default::default(), + items: &mut context, + history: &[], + }) + .await + .unwrap(); + assert!( + matches!(result, PreRequestAction::Cancel(message) if message.contains("no summary was truncated")) + ); + let interceptor = + CompactWorkerInterceptor::new(Arc::new(UsageTracker::new()), 1000, 1000, 0, None); + let result = interceptor + .pre_llm_request(PreLlmRequestContext::<()> { + invocation: Default::default(), + items: &mut context, + history: &[], + }) + .await + .unwrap(); + let PreRequestAction::ContinueWith(items) = result else { + panic!("expected persisted warning") + }; + let warning = items[0].as_text().unwrap(); + for policy in [ + "full initial summary and segment", + "prohibitions", + "completion conditions", + "exception-reporting duties", + "changes/retractions", + "uncertainty", + "Compress duplicate", + ] { + assert!(warning.contains(policy), "missing warning policy: {policy}"); + } + } + + #[tokio::test] + async fn compact_worker_initial_request_counts_system_shape_but_measurements_do_not_double_count() + { + let tracker = Arc::new(UsageTracker::new()); + let interceptor = CompactWorkerInterceptor::new(tracker.clone(), 100, 0, 0, None) + .with_request_shape( + "system".repeat(100), + agen::tool_server::ToolServerHandle::default(), + ); + let mut context = vec![Item::user_message("full input")]; + assert!(matches!( + interceptor + .pre_llm_request(PreLlmRequestContext::<()> { + invocation: Default::default(), + items: &mut context, + history: &[], + }) + .await + .unwrap(), + PreRequestAction::Cancel(_) + )); + tracker.note_request(context.len()); + tracker.record_usage(&make_usage(50)); + assert!(matches!( + interceptor + .pre_llm_request(PreLlmRequestContext::<()> { + invocation: Default::default(), + items: &mut context, + history: &[], + }) + .await + .unwrap(), + PreRequestAction::Continue + )); + } + #[tokio::test] async fn compact_worker_interceptor_warns_before_hard_cap() { let tracker = Arc::new(UsageTracker::new()); @@ -542,8 +673,8 @@ mod tests { let captured = warnings.clone(); let interceptor = CompactWorkerInterceptor::new( tracker.clone(), - 150, - 60, + 500, + 410, 20, Some(Arc::new(move |message| { captured.lock().unwrap().push(message); diff --git a/crates/worker/src/feature/builtin/flow_transition.rs b/crates/worker/src/feature/builtin/flow_transition.rs index 457062217..e3a9fc196 100644 --- a/crates/worker/src/feature/builtin/flow_transition.rs +++ b/crates/worker/src/feature/builtin/flow_transition.rs @@ -574,6 +574,7 @@ where attempt.instance_id, attempt.attempt_id )), max_turns: Some(12), + compaction_budget: None, engine_configurator: None, features, required_tools: &[ diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index 96dab0b7e..b320afec6 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -473,6 +473,9 @@ pub struct WorkerLifecycleWorkdirAttachment { #[derive(Debug, Clone)] pub struct WorkerLifecycleSpawnRequest { + /// Observed delegation required for Orchestrator Ticket starts. + pub orchestrator_assignment_id: Option, + pub runtime_id: String, /// Ordered alias-keyed selections forwarded as canonical Workdir attachments. pub workdir_attachments: Vec, @@ -494,6 +497,7 @@ fn workspace_worker_create_request( let ticket_assignment = match (request.ticket_id, request.operation_id) { (Some(ticket_id), Some(operation_id)) => { Some(CreateWorkspaceWorkerTicketAssignmentRequest { + orchestrator_assignment_id: request.orchestrator_assignment_id, ticket_id, operation_id, }) @@ -729,6 +733,9 @@ struct WorkerSpawnInput { /// neither the profile nor an initial Flow segment grants review authority. #[serde(default)] ticket_id: Option, + /// Observed current Orchestrator delegation, required for a Ticket start by the Orchestrator. + #[serde(default)] + orchestrator_assignment_id: Option, #[serde(default)] display_name: Option, /// Normal typed initial user submission delivered after spawn. An empty @@ -913,6 +920,10 @@ impl Tool for WorkspaceWorkerTool { .transpose()?; self.control .spawn_worker(WorkerLifecycleSpawnRequest { + orchestrator_assignment_id: input + .orchestrator_assignment_id + .map(|id| authority_id(&id, "orchestrator_assignment_id")) + .transpose()?, runtime_id: authority_id(&input.runtime_id, "runtime_id")?, workdir_attachments: vec![WorkerLifecycleWorkdirAttachment { alias: INITIAL_WORKDIR_ALIAS.to_string(), @@ -1895,6 +1906,7 @@ mod tests { #[test] fn worker_lifecycle_spawn_request_preserves_canonical_attachment_collection() { let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest { + orchestrator_assignment_id: None, runtime_id: "runtime-1".to_string(), workdir_attachments: vec![ WorkerLifecycleWorkdirAttachment { @@ -1954,6 +1966,7 @@ mod tests { #[test] fn worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow() { let request = workspace_worker_create_request(WorkerLifecycleSpawnRequest { + orchestrator_assignment_id: Some("delegation-742".into()), runtime_id: "runtime-1".to_string(), workdir_attachments: Vec::new(), profile: "project:ticket-worker".to_string(), @@ -1971,6 +1984,7 @@ mod tests { value["ticket_assignment"], serde_json::json!({ "ticket_id": "T-719", + "orchestrator_assignment_id": "delegation-742", "operation_id": "spawn-ticket-worker:T-719:call-1", }) ); diff --git a/crates/worker/src/feature/builtin/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 68307a8a5..94a2a2481 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -574,6 +574,7 @@ impl MemoryLifecycleTask { .extraction .worker_max_turns .or(manifest::defaults::MEMORY_EXTRACT_WORKER_MAX_TURNS), + compaction_budget: None, engine_configurator: None, features, required_tools: &[ diff --git a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs index 324c6beeb..376a41a99 100644 --- a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -388,6 +388,7 @@ impl SubjektivSurfaceLifecycleTask { input, cache_key: None, max_turns: Some(EDITOR_MAX_TURNS), + compaction_budget: None, engine_configurator: None, features, required_tools: &["SubmitMemorySurface"], diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index bf864d6c9..495c382cf 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -21,7 +21,7 @@ use crate::feature::{ const FEATURE_ID: &str = "orchestration"; const TOOL_NAME: &str = "SpawnTicketWorker"; -const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority."; +const TOOL_DESCRIPTION: &str = "Spawn and atomically assign a generic Ticket Worker for the natural-language initial request. Profile is optional (defaults to builtin:ticket-worker); Flow and repository/Workdir attachments are optional. Results and return choices follow user intent, not a universal approval or MR gate. Orchestrator starts must supply the observed orchestrator_assignment_id; stale delegations cannot start new work. Assign is enough (no Queue/ready); unresolved dependencies are diagnostics for partial work and never start implicitly. Backend validates claims and derives attachment access from Ticket targets; acceptance follows spawn, initial input, assignment, and resource finalization. Profile/Flow selection grants neither trusted review nor merge authority."; #[derive(Debug, Default)] pub struct OrchestrationFeature; @@ -78,6 +78,9 @@ struct SpawnTicketWorkerWorkdirInput { #[serde(deny_unknown_fields)] struct SpawnTicketWorkerInput { ticket_id: String, + /// Observed current delegation identity. Required by Backend for an Orchestrator start; manual callers omit it. + #[serde(default)] + orchestrator_assignment_id: Option, runtime_id: String, /// Optional registered profile selector; omitted/null selects builtin:ticket-worker. /// Backend resolves the profile and validates claims, not natural-language satisfaction. @@ -132,6 +135,10 @@ impl Tool for SpawnTicketWorkerTool { let response = self .worker_service .spawn(WorkerLifecycleSpawnRequest { + orchestrator_assignment_id: input + .orchestrator_assignment_id + .map(|id| authority_id(id, "orchestrator_assignment_id")) + .transpose()?, runtime_id, workdir_attachments, profile, @@ -247,13 +254,13 @@ fn validate_relative_cwd(value: String) -> Result { mod tests { use std::sync::Mutex; - use ticket::{TicketError, TicketWorkflowState}; + use ticket::{TicketError, TicketState}; use super::*; use crate::feature::builtin::ticket::TicketHandoff; use crate::worker::{WorkspaceClientError, WorkspaceResponse}; - struct FixedTicketService(TicketWorkflowState); + struct FixedTicketService(TicketState); impl TicketService for FixedTicketService { fn ticket_handoff(&self, ticket_ref: &str) -> Result { @@ -295,7 +302,7 @@ mod tests { }) } - fn tool(service: Arc, state: TicketWorkflowState) -> SpawnTicketWorkerTool { + fn tool(service: Arc, state: TicketState) -> SpawnTicketWorkerTool { SpawnTicketWorkerTool { ticket_service: Arc::new(FixedTicketService(state)), worker_service: service, @@ -307,6 +314,7 @@ mod tests { let service = Arc::new(RecordingService::default()); let mut input = input(); input["flow"] = serde_json::json!("project:investigation"); + input["orchestrator_assignment_id"] = serde_json::json!("delegation-742"); input["workdir_attachments"] = serde_json::json!([ { "alias": "checkout", @@ -315,7 +323,7 @@ mod tests { }, {"alias": "docs", "working_directory_id": "workdir-2"} ]); - let output = tool(service.clone(), TicketWorkflowState::Queued) + let output = tool(service.clone(), TicketState::Open) .execute( &input.to_string(), ToolExecutionContext::new("call-7", "batch-1", 0), @@ -327,6 +335,10 @@ mod tests { assert_eq!(requests.len(), 1); let request = &requests[0]; assert_eq!(request.runtime_id, "runtime-1"); + assert_eq!( + request.orchestrator_assignment_id.as_deref(), + Some("delegation-742") + ); assert_eq!(request.profile, "project:ticket-worker"); assert_eq!(request.ticket_id.as_deref(), Some("00001KZXN51C7")); assert_eq!( @@ -372,7 +384,7 @@ mod tests { input["workdir_attachments"] = serde_json::json!([]); input["flow"] = serde_json::Value::Null; } - tool(service.clone(), TicketWorkflowState::Queued) + tool(service.clone(), TicketState::Open) .execute( &input.to_string(), ToolExecutionContext::new("call-empty", "batch-1", 0), @@ -395,7 +407,7 @@ mod tests { async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() { let service = Arc::new(RecordingService::default()); let definition = definition( - Arc::new(FixedTicketService(TicketWorkflowState::Ready)), + Arc::new(FixedTicketService(TicketState::Open)), service.clone(), ); let (meta, tool) = definition(); @@ -434,33 +446,35 @@ mod tests { } #[tokio::test] - async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() { - let service = Arc::new(RecordingService { - response: Some(WorkspaceResponse { - status: 409, - body: "Backend rejected Ticket claim".into(), - }), - ..Default::default() - }); - let error = tool(service.clone(), TicketWorkflowState::Planning) - .execute( - &input().to_string(), - ToolExecutionContext::new("call-claim", "batch-1", 0), - ) - .await - .unwrap_err(); - assert_eq!(service.requests.lock().unwrap().len(), 1); - assert!( - error - .to_string() - .contains("HTTP 409: Backend rejected Ticket claim") - ); + async fn spawn_ticket_worker_defers_claim_eligibility_to_backend_not_case_state() { + for state in [TicketState::Open, TicketState::Closed] { + let service = Arc::new(RecordingService { + response: Some(WorkspaceResponse { + status: 409, + body: "Backend rejected Ticket claim".into(), + }), + ..Default::default() + }); + let error = tool(service.clone(), state) + .execute( + &input().to_string(), + ToolExecutionContext::new("call-claim", "batch-1", 0), + ) + .await + .unwrap_err(); + assert_eq!(service.requests.lock().unwrap().len(), 1); + assert!( + error + .to_string() + .contains("HTTP 409: Backend rejected Ticket claim") + ); + } } #[tokio::test] async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() { let service = Arc::new(RecordingService::default()); - let tool = tool(service.clone(), TicketWorkflowState::Queued); + let tool = tool(service.clone(), TicketState::Open); let cases = [ ( "profile", @@ -473,6 +487,16 @@ mod tests { "initial_request must not be empty", ), ("flow", serde_json::json!(" "), "flow must not be empty"), + ( + "orchestrator_assignment_id", + serde_json::json!("http://invalid/id"), + "orchestrator_assignment_id must be an authority id", + ), + ( + "orchestrator_assignment_id", + serde_json::json!(" "), + "orchestrator_assignment_id must not be empty", + ), ( "runtime_id", serde_json::json!("runtime/1"), @@ -603,7 +627,7 @@ mod tests { #[test] fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() { let definition = definition( - Arc::new(FixedTicketService(TicketWorkflowState::Queued)), + Arc::new(FixedTicketService(TicketState::Open)), Arc::new(RecordingService::default()), ); let (meta, _) = definition(); diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index e249066b4..9d149688e 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -13,7 +13,12 @@ pub(super) struct ModelTicketQueryResponse { struct ModelTicketQueryItem { ticket: String, title: String, - state: String, + state: ticket::TicketState, + closure_operation_key: Option, + current_closure: Option, + legacy_handoff: Option, + assignment_tracking: Vec, + tags: Vec, readiness: Option, priority: Option, created_at: Option, @@ -38,11 +43,17 @@ pub(super) struct ModelTicketDetail { content_digest: String, title: String, body: String, - state: String, + state: ticket::TicketState, + closure_operation_key: Option, + current_closure: Option, + legacy_handoff: Option, + assignment_tracking: Vec, + tags: Vec, readiness: Option, priority: Option, created_at: Option, updated_at: Option, + closure_history: Vec, thread: Vec, relations: ModelTicketRelations, linked_objectives: Vec, @@ -89,13 +100,36 @@ pub(super) struct ModelObjectiveDetail { event_page: ModelObjectiveEventPage, } +#[derive(Debug, Serialize)] +struct ModelAssignmentTracking { + assignment_id: String, + role: String, + principal: ticket::TicketAssignmentPrincipalSnapshot, + assigned_by: String, + assigned_at: String, + release: Option, + current: bool, + tracking: Option, + last_event_id: Option, + updated_at: Option, + migration_diagnostic: Option, +} + #[derive(Debug, Serialize)] struct ModelWorkerSummary { worker: String, } +#[derive(Debug, Serialize)] +struct ModelTicketTagChange { + before: Vec, + after: Vec, +} + #[derive(Debug, Serialize)] struct ModelTicketEvent { + #[serde(skip_serializing_if = "Option::is_none")] + tag_changes: Option, sequence: usize, kind: String, body: Option, @@ -216,6 +250,16 @@ struct ModelObjectiveEventPage { has_more: bool, } +fn typed_field( + root: &Map, + key: &str, +) -> Result { + let value = root + .get(key) + .ok_or_else(|| format!("Ticket projection requires {key}"))?; + serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}")) +} + pub(super) fn project_ticket_query(value: Value) -> Result { let root = object(&value, "Ticket query response")?; let page = object_field(root, "page")?; @@ -235,7 +279,12 @@ fn project_ticket_query_item(value: &Value) -> Result Result Result { fn project_ticket_event(value: &Value) -> Result { let event = object(value, "Ticket event")?; + let tag_changes = if event.get("kind").and_then(Value::as_str) == Some("tags_changed") { + let attributes = event + .get("attributes") + .ok_or("missing tag audit attributes")?; + let snapshot = |field| -> Result, String> { + let encoded = attributes + .get(field) + .and_then(Value::as_str) + .ok_or("missing tag audit snapshot")?; + serde_json::from_str(encoded) + .map_err(|error| format!("invalid tag audit snapshot: {error}")) + }; + Some(ModelTicketTagChange { + before: snapshot("tags_before")?, + after: snapshot("tags_after")?, + }) + } else { + None + }; Ok(ModelTicketEvent { + tag_changes, sequence: usize_field(event, "sequence")?, kind: string_field(event, "kind")?, body: match event.get("body") { @@ -616,6 +691,54 @@ fn object_field<'a>( .ok_or_else(|| format!("missing or invalid {key}")) } +fn project_assignment_tracking( + object: &Map, +) -> Result, String> { + let values = array_field(object, "assignment_tracking")?; + if values.len() > 1 { + return Err("Ticket has more than one current Orchestrator delegation".into()); + } + values + .iter() + .map(|value| { + let projection: ticket::TicketAssignmentTrackingProjection = + serde_json::from_value(value.clone()) + .map_err(|error| format!("invalid assignment tracking: {error}"))?; + Ok(ModelAssignmentTracking { + assignment_id: projection.assignment_id, + role: projection.role, + principal: projection.principal, + assigned_by: projection.assigned_by, + assigned_at: projection.assigned_at, + release: projection.release, + current: projection.current, + tracking: projection.tracking, + last_event_id: projection.last_event_id, + updated_at: projection.updated_at, + migration_diagnostic: projection.migration_diagnostic, + }) + }) + .collect() +} + +fn project_tags(object: &Map) -> Result, String> { + let values = array_field(object, "tags")?; + if values.len() > 100 { + return Err("Ticket projection exceeds 100 tags".into()); + } + values + .iter() + .map(|value| { + let tag: ticket::WorkspaceTag = serde_json::from_value(value.clone()) + .map_err(|error| format!("invalid Workspace tag: {error}"))?; + if tag.deleted { + return Err("current Ticket projection contains a deleted tag".into()); + } + Ok(tag) + }) + .collect() +} + fn array_field<'a>(object: &'a Map, key: &str) -> Result<&'a [Value], String> { object .get(key) @@ -694,16 +817,10 @@ mod tests { #[test] fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() { - for state in [ - "planning", - "ready", - "queued", - "inprogress", - "done", - "closed", - ] { + for state in ["open", "closed"] { let projected = project_ticket_detail(json!({ - "resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision", + "resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", + "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}], "linked_objectives": [], "assignments": [], "implementation_reports": [], "merge_requests": [], "merge_request": null, @@ -724,6 +841,57 @@ mod tests { } } + #[test] + fn reopened_ticket_projection_preserves_closure_observation_history_and_legacy_handoff() { + let history = json!([ + { + "operation_key": "close-1", "previous_closure_operation_key": null, + "action": {"action": "close", "reason": "not_planned"}, + "explanation": "Not proceeding", "references": [{"kind": "ticket", "target": "T-12"}], + "author": "workspace-user:alice", "at": "2026-01-01T00:00:00Z" + }, + { + "operation_key": "reopen-1", "previous_closure_operation_key": "close-1", + "action": {"action": "reopen"}, "explanation": "New information", + "references": [], "author": "workspace-user:bob", "at": "2026-01-02T00:00:00Z" + } + ]); + let legacy = json!({ + "workflow_state": "queued", "workflow_state_explicit": true, "status": "open", + "queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null + }); + let response = json!({ + "assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}], + "resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", + "state": "open", "closure_operation_key": "close-1", "current_closure": null, + "closure_history": history, "legacy_handoff": legacy, + "events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [], + "merge_requests": [], "merge_request": null + }); + let projected = + serde_json::to_value(project_ticket_detail(response.clone()).unwrap()).unwrap(); + assert_eq!(projected["state"], "open"); + assert_eq!(projected["closure_operation_key"], "close-1"); + assert_eq!(projected["current_closure"], Value::Null); + assert_eq!(projected["closure_history"], history); + assert_eq!(projected["legacy_handoff"], legacy); + assert_eq!(projected["tags"], response["tags"]); + assert_eq!(projected["content_digest"], "content-digest"); + + // Missing observations must not silently become null: the model must be + // able to distinguish an observed never-closed case from unknown data. + let mut missing = response; + missing + .as_object_mut() + .unwrap() + .remove("closure_operation_key"); + assert!( + project_ticket_detail(missing) + .unwrap_err() + .contains("closure_operation_key") + ); + } + #[test] fn objective_projection_exposes_only_resource_references() { let projected = project_objective_detail(json!({ @@ -738,9 +906,9 @@ mod tests { "linked_tickets": ["00001M0E82D1V"], "linked_ticket_summaries": [{ "id": "00001M0E82D1V", - "resource_key": "T-496", + "resource_key": "T-496", "assignment_tracking": [], "tags": [], "title": "Ticket", - "state": "done", + "state": "closed", "updated_at": "2026-01-02T00:00:00Z" }], "events": [{ @@ -769,9 +937,10 @@ mod tests { "record_authority": "workspace_sqlite", "items": [{ "id": "00001TICKETINTERNAL", - "resource_key": "T-543", + "resource_key": "T-543", "assignment_tracking": [], "tags": [], "title": "Ticket", - "state": "inprogress", + "state": "open", + "closure_operation_key": null, "current_closure": null, "legacy_handoff": null, "readiness": null, "priority": "high", "created_at": null, @@ -840,11 +1009,12 @@ mod tests { fn ticket_detail_projection_accepts_current_blocker_shape() { let projected = project_ticket_detail(json!({ "id": "internal-ticket", - "resource_key": "T-588", + "resource_key": "T-588", "assignment_tracking": [], "tags": [], "content_digest": "ticket-content-digest", + "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "title": "Queued Submit", "body": "Body", - "state": "planning", + "state": "open", "readiness": null, "priority": "P2", "created_at": "2026-09-03T00:00:00Z", @@ -859,7 +1029,7 @@ mod tests { "reason_kind": "depends_on", "relation_kind": "depends_on", "note": "required foundation", - "blocking_state": "planning" + "blocking_state": "open" }], "notices": [] }, @@ -898,7 +1068,7 @@ mod tests { json!([{ "ticket": "T-584", "kind": "depends_on", - "state": "planning" + "state": "open" }]) ); assert!(!projected.to_string().contains("internal-blocker")); @@ -943,11 +1113,12 @@ mod tests { "review_excerpt": "Reviewed source" }); let projected = project_ticket_detail(json!({ - "resource_key": "T-716", + "resource_key": "T-716", "assignment_tracking": [], "tags": [], "content_digest": "current-ticket-content-digest", + "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "title": "Source evidence", "body": "Body", - "state": if merged { "done" } else { "inprogress" }, + "state": "open", "events": [], "linked_objectives": [], "assignments": [], @@ -1123,4 +1294,132 @@ mod tests { .expect_err("missing W-key must fail"); assert!(error.contains("W-")); } + #[test] + fn ticket_query_and_detail_preserve_case_classification_and_delegation_independently() { + let tracking = json!({"workspace_id":"ws", "ticket_id":"internal-id", "assignment_id":"delegation", "role":"orchestrator", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}, "assigned_by":"workspace-user:alice", "assigned_at":"2026-01-01T00:00:00Z", "release":null, "current":true, "last_event_id":"reported", "updated_at":"2026-01-01T00:00:00Z", "migration_diagnostic":null, "tracking":{"status":"waiting", "waiting_reason":"Human answer", "progress":null, "next_action":"Compare the response", "result":null}}); + let tag = json!({"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}); + let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}); + let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]); + let detail = json!({ + "resource_key":"T-739", "title":"Case", "body":"Requirements", "content_digest":"content-1", + "state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure, + "legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[] + }); + let mut item = detail.clone(); + item["matched_fields"] = json!([]); + item["linked_objective_keys"] = json!([]); + for field in [ + "relation_count", + "blocker_count", + "unresolved_blocker_count", + "unresolved_review_count", + ] { + item[field] = json!(0); + } + let projected_detail = + serde_json::to_value(project_ticket_detail(detail.clone()).unwrap()).unwrap(); + let projected_query = serde_json::to_value( + project_ticket_query( + json!({"items":[item.clone()], "page":{"has_more":false, "next_cursor":null}}), + ) + .unwrap(), + ) + .unwrap(); + for field in [ + "tags", + "state", + "closure_operation_key", + "current_closure", + "legacy_handoff", + ] { + assert_eq!(projected_detail[field], detail[field], "detail {field}"); + assert_eq!( + projected_query["tickets"][0][field], detail[field], + "query {field}" + ); + } + for projected in [&projected_detail, &projected_query["tickets"][0]] { + let actual = &projected["assignment_tracking"][0]; + assert_eq!(actual["assignment_id"], "delegation"); + assert_eq!(actual["last_event_id"], "reported"); + assert_eq!(actual["tracking"], tracking["tracking"]); + assert!(actual.get("workspace_id").is_none()); + assert!(actual.get("ticket_id").is_none()); + } + assert_eq!(projected_detail["closure_history"], history); + assert_eq!(projected_detail["content_digest"], "content-1"); + // Unknown observations must not be replaced with empty/null values. + for field in [ + "tags", + "closure_operation_key", + "current_closure", + "legacy_handoff", + "state", + ] { + let mut missing_detail = detail.clone(); + missing_detail.as_object_mut().unwrap().remove(field); + assert!( + project_ticket_detail(missing_detail) + .unwrap_err() + .contains(field), + "detail {field}" + ); + let mut missing_item = item.clone(); + missing_item.as_object_mut().unwrap().remove(field); + assert!( + project_ticket_query( + json!({"items":[missing_item], "page":{"has_more":false, "next_cursor":null}}) + ) + .unwrap_err() + .contains(field), + "query {field}" + ); + } + for invalid_tags in [ + json!([{"id":"tag-1"}]), + json!(vec![detail["tags"][0].clone(); 101]), + { + let mut deleted = detail["tags"].clone(); + deleted[0]["deleted"] = json!(true); + deleted + }, + ] { + let mut malformed = detail.clone(); + malformed["tags"] = invalid_tags; + assert!(project_ticket_detail(malformed).is_err()); + } + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "unknown", + ] { + let mut malformed = detail.clone(); + malformed["state"] = json!(state); + assert!( + project_ticket_detail(malformed) + .unwrap_err() + .contains("state"), + "{state}" + ); + } + } + + #[test] + fn ticket_tag_history_projection_preserves_observed_names_and_identities() { + let tag = serde_json::json!({"workspace_id":"ws","id":"tag-1","name":"Old name","last_operation_key":"create","deleted":false}); + let event = serde_json::json!({"sequence":3,"kind":"tags_changed","body":"Ticket tags changed","at":"then","attributes":{"tags_before":serde_json::json!([tag.clone()]).to_string(),"tags_after":"[]"}}); + let projected = serde_json::to_value(project_ticket_event(&event).unwrap()).unwrap(); + assert_eq!(projected["tag_changes"]["before"], serde_json::json!([tag])); + assert_eq!(projected["tag_changes"]["after"], serde_json::json!([])); + let ordinary = serde_json::json!({"sequence":0,"kind":"comment","body":"hello","at":null}); + assert!( + serde_json::to_value(project_ticket_event(&ordinary).unwrap()) + .unwrap() + .get("tag_changes") + .is_none() + ); + } } diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index a653dd7bb..140e1ea62 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -13,6 +13,16 @@ //! Operations come from regular Tools actually enabled by `TicketFeatureAccess`, retaining original //! schemas and permission identities. WIP adds neither a broader business catalog nor another workflow. //! +//! # Independent judgments and delegation +//! +//! Case judgments, classification, delegation handling, delivery/start evidence, and Worker +//! lifecycle retain their separate Backend contracts. Case updates compare content, state, and +//! the explicitly observed nullable closure key; they require no universal MR approval and create +//! none. Handling done neither closes a case nor releases work; reopening does not reissue a +//! delegation or restore execution rights. Dependencies remain diagnostics, not blanket start gates. +//! Classification mutations use stable tag IDs, tag-operation CAS, or full membership-set CAS, +//! never display names, case state, or the editable content digest as classification authority. +//! //! # Bounded responses and observation invalidation //! //! Query returns bounded snippets and canonical item paths; read returns bounded Backend details. @@ -28,6 +38,10 @@ //! Optional arguments distinguish omission from explicit null for original JSON Schema/permission //! evaluation; defaults use the same implementation as regular Tools. +mod assignment_public; +mod assignment_tracking; +mod tags; + use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Arc, Mutex}; @@ -38,11 +52,11 @@ use serde::{Deserialize, Serialize}; use serde_json::{Value, json}; use sha2::{Digest, Sha256}; use ticket::{ - MarkdownText, NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, + NewOrchestrationPlanRecord, NewTicket, NewTicketEvent, NewTicketRelation, OrchestrationPlanKind, OrchestrationPlanRecord, Result as TicketResult, Ticket, TicketBackend, TicketBackendOperation, TicketBackendOperationResult, TicketDoctorReport, TicketError, TicketIdOrSlug, TicketIntakeSummary, TicketListQuery, TicketRef, TicketRelation, - TicketRelationKind, TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, + TicketRelationKind, TicketRelationView, TicketState, TicketStateChange, TicketSummary, tool::{TICKET_TOOL_NAMES, TicketToolBackend, ticket_tool_description, ticket_tools}, }; use wip_protocol::{ @@ -102,17 +116,6 @@ impl WorkspaceTicketReadKind { } } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -#[serde(rename_all = "snake_case")] -enum WorkspaceTicketStateFilter { - Planning, - Ready, - Queued, - Inprogress, - Done, - Closed, -} - #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum WorkspaceTicketEvidenceFilter { @@ -135,12 +138,10 @@ enum WorkspaceTicketReviewFilter { #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum WorkspaceTicketAttentionFilter { - DoneNotClosed, UnresolvedReview, MissingCommit, Blocked, Unblocked, - Ready, AwaitingReview, UnresolvedChanges, StaleAfterRescope, @@ -168,12 +169,19 @@ enum WorkspaceTicketSort { } #[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] struct WorkspaceQueryTicketInput { + /// Exact current closed-case reason; reopened/history-only reasons do not match. + closure_reason: Option, + /// Exact current assigned principal; retained relationships do not match. + assigned_principal: Option, + /// Explicit self-reported handling, never inferred from Worker/MR/delivery. + handling_status: Option, /// Full-text match over Ticket title, item body, and bounded thread excerpts. query: Option, - /// Exact workflow states. Empty means every state. + /// Exact case states (open or closed). Empty means every state. #[serde(default)] - states: Vec, + states: Vec, /// Exact typed event kinds that must occur in the bounded thread window. #[serde(default)] event_kinds: Vec, @@ -183,14 +191,26 @@ struct WorkspaceQueryTicketInput { /// Current authoritative Merge Request review status: none, pending, approved, /// request_changes, or unresolved_changes. review_status: Option, - /// Attention filters include done_not_closed, unresolved_review, missing_commit, - /// blocked, unblocked, ready, awaiting_review, unresolved_changes, + /// Attention filters include unresolved_review, missing_commit, + /// blocked, unblocked, awaiting_review, unresolved_changes, /// stale_after_rescope, and missing_evidence. #[serde(default)] attention: Vec, /// Related Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. related_ticket_id: Option, relation_kind: Option, + /// All stable tag IDs must be attached (AND); at most 100 unique IDs. + #[serde(default)] + #[schemars(length(max = 100))] + tag_ids: Vec, + /// At least one stable tag ID must be attached (OR), unless empty. + #[serde(default)] + #[schemars(length(max = 100))] + tag_ids_any: Vec, + /// None of these stable tag IDs may be attached (NOT). + #[serde(default)] + #[schemars(length(max = 100))] + tag_ids_none: Vec, /// Linked Objective reference. Prefer `O-*`; canonical internal ids remain accepted for compatibility. linked_objective_id: Option, updated_after: Option, @@ -234,6 +254,13 @@ impl Tool for WorkspaceTicketReadTool { WorkspaceTicketReadKind::Query => { let input: WorkspaceQueryTicketInput = serde_json::from_str(&input) .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; + for (name, ids) in [ + ("tag_ids", &input.tag_ids), + ("tag_ids_any", &input.tag_ids_any), + ("tag_ids_none", &input.tag_ids_none), + ] { + tags::validate_ids(name, ids)?; + } ( format!("/api/w/{workspace_id}/tickets/query"), serde_json::to_value(input) @@ -311,88 +338,58 @@ fn workspace_ticket_read_definition( #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] -struct WorkspaceCompleteTicketInput { - /// Ticket reference. Prefer `T-*`. +struct WorkspaceTicketCaseUpdateInput { + /// Ticket reference. Prefer T-*; WIP binds this from the item route. ticket: String, - /// Stable key for exact replay of this completion decision. + /// Stable key for exact replay of this case judgment. operation_key: String, /// SHA-256 of the current editable Ticket content from ShowTicket. expected_content_digest: String, - expected_state: TicketWorkflowState, - /// Required explanation of the completion decision; MR approval is not a prerequisite. - reason: String, - /// Optional typed supporting Ticket references, independent of MR integration evidence. + expected_state: ticket::TicketState, + /// Explicitly observed null for never closed, or current closure operation key. + #[serde(deserialize_with = "deserialize_observed_closure_key")] + expected_closure_operation_key: Option, + action: ticket::TicketCaseAction, + /// Required explanation of the judgment, independent of Repository/MR evidence. + explanation: String, #[serde(default)] references: Vec, } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct WorkspaceTicketStateUpdateInput { - /// Ticket reference. Prefer `T-*`. - ticket: String, - operation_key: String, - /// SHA-256 of the current editable Ticket content from ShowTicket. - expected_content_digest: String, - expected_state: TicketWorkflowState, - /// Progress-display state only: this does not start Workers or integrate Merge Requests. - state: TicketWorkflowState, - /// Required explanation for the state decision. - reason: String, - #[serde(default)] - references: Vec, +fn deserialize_observed_closure_key<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::::deserialize(deserializer) } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct WorkspaceTicketCloseInput { - /// Ticket reference. Prefer `T-*`; WIP binds this from the item route. - ticket: String, - /// Stable key for exact replay of this close decision. - operation_key: String, - /// SHA-256 of the current editable Ticket content from ShowTicket. - expected_content_digest: String, - expected_state: TicketWorkflowState, - /// Required explanation for closing this Ticket. - resolution: String, - #[serde(default)] - references: Vec, -} - -const TICKET_CLOSE_DESCRIPTION: &str = "Record an authorized Ticket closure decision with resolution, item/state CAS, and replay key when the requested return path calls for closure. No Merge Request or enforced state sequence is required. Backend checks actor authority, not natural-language satisfaction; this does not integrate MRs, stop/remove Workers, clean attachments, or expand Workdir grants."; -const COMPLETE_TICKET_DESCRIPTION: &str = "Record an authorized Ticket completion decision with reason, item/state CAS, and replay key when user intent calls for completion; return without conclusion need not call this tool. Merge Requests and approvals are not prerequisites. Backend checks assigned Worker/Workspace Orchestrator authority, not real judgment of natural-language satisfaction; model-supplied roles never grant authority. Conclusion ends unfinished work while retaining terminal responsibility for display; it does not grant review/merge authority or stop/remove Workers and attachments."; -const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. Backend checks actor authority, not natural-language satisfaction. This does not start/stop Workers, queue dependencies, integrate Merge Requests, clean attachments, or enlarge live Workdir grants."; +const TICKET_CASE_UPDATE_DESCRIPTION: &str = "Record an authorized Ticket close, reopen, or closure-correction judgment with an explicit observed closure key, item/state CAS, and replay key. Open means the case remains to be handled, not ready/queued/inprogress; Closed does not imply success. Repository, Merge Request and approval evidence are not prerequisites. Return without conclusion need not call this tool. Backend checks actor authority, not real judgment of natural-language satisfaction; actor identity is injected by the server. This does not start/stop Workers, restore execution rights, change assignments, grant resources, approve or integrate Merge Requests."; #[derive(Clone, Copy)] enum WorkspaceTicketDecisionKind { - Complete, - UpdateState, - Close, + CaseUpdate, } impl WorkspaceTicketDecisionKind { fn name(self) -> &'static str { - match self { - Self::Complete => "CompleteTicket", - Self::UpdateState => "TicketWorkflowState", - Self::Close => "TicketClose", - } + "TicketCaseUpdate" } - fn description(self) -> &'static str { - match self { - Self::Complete => COMPLETE_TICKET_DESCRIPTION, - Self::UpdateState => TICKET_STATE_UPDATE_DESCRIPTION, - Self::Close => TICKET_CLOSE_DESCRIPTION, - } + TICKET_CASE_UPDATE_DESCRIPTION } - fn schema(self) -> Value { - match self { - Self::Complete => json!(schemars::schema_for!(WorkspaceCompleteTicketInput)), - Self::UpdateState => json!(schemars::schema_for!(WorkspaceTicketStateUpdateInput)), - Self::Close => json!(schemars::schema_for!(WorkspaceTicketCloseInput)), + let mut schema = json!(schemars::schema_for!(WorkspaceTicketCaseUpdateInput)); + // Observation must be present even when it is explicitly null. Keep the + // Option's nullable wire schema instead of treating it as a String. + let required = schema["required"] + .as_array_mut() + .expect("object schema required fields"); + if !required + .iter() + .any(|field| field == "expected_closure_operation_key") + { + required.push(json!("expected_closure_operation_key")); } + schema } } @@ -402,97 +399,62 @@ struct WorkspaceTicketDecisionTool { kind: WorkspaceTicketDecisionKind, } -fn validate_ticket_decision( - ticket: &str, - operation_key: &str, - content_digest: &str, - reason: &str, -) -> Result<(), ToolError> { - for (name, value) in [ - ("ticket", ticket), - ("operation_key", operation_key), - ("expected_content_digest", content_digest), - ("reason", reason), - ] { - if value.trim().is_empty() { - return Err(ToolError::InvalidArgument(format!( - "{name} must not be empty" - ))); - } - } - Ok(()) -} - #[async_trait] impl Tool for WorkspaceTicketDecisionTool { async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { - let workspace_id = self.client.workspace_id().ok_or_else(|| { + self.client.workspace_id().ok_or_else(|| { ToolError::InvalidArgument("Ticket decisions require Workspace identity".into()) })?; - let (ticket, endpoint, body) = match self.kind { - WorkspaceTicketDecisionKind::Complete => { - let value: WorkspaceCompleteTicketInput = serde_json::from_str(input) - .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; - validate_ticket_decision( - &value.ticket, - &value.operation_key, - &value.expected_content_digest, - &value.reason, - )?; - let mut body = serde_json::to_value(&value) - .map_err(|error| ToolError::Internal(error.to_string()))?; - body.as_object_mut() - .expect("typed Ticket input is an object") - .remove("ticket"); - (value.ticket, "complete", body) - } - WorkspaceTicketDecisionKind::Close => { - let value: WorkspaceTicketCloseInput = serde_json::from_str(input) - .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; - validate_ticket_decision( - &value.ticket, - &value.operation_key, - &value.expected_content_digest, - &value.resolution, - )?; - let body = json!({ - "operation_key": value.operation_key, - "expected_content_digest": value.expected_content_digest, - "expected_state": value.expected_state, - "state": TicketWorkflowState::Closed, - "reason": value.resolution, - "references": value.references, - }); - (value.ticket, "state-update", body) - } - WorkspaceTicketDecisionKind::UpdateState => { - let value: WorkspaceTicketStateUpdateInput = serde_json::from_str(input) - .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; - validate_ticket_decision( - &value.ticket, - &value.operation_key, - &value.expected_content_digest, - &value.reason, - )?; - let mut body = serde_json::to_value(&value) - .map_err(|error| ToolError::Internal(error.to_string()))?; - body.as_object_mut() - .expect("typed Ticket input is an object") - .remove("ticket"); - // Actor identity is transport-bound, never a model-visible author/role input. - (value.ticket, "state-update", body) - } - }; - let ticket: Ticket = WorkspaceHttpTicketBackend::request( - self.client.clone(), - WorkspaceRequestMethod::Post, - format!( - "/api/w/{workspace_id}/tickets/{}/{endpoint}", - WorkspaceHttpTicketBackend::ticket_path(&TicketIdOrSlug::from(ticket.as_str())) + let value: WorkspaceTicketCaseUpdateInput = serde_json::from_str(input) + .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; + for (name, value) in [ + ("ticket", value.ticket.as_str()), + ("operation_key", value.operation_key.as_str()), + ( + "expected_content_digest", + value.expected_content_digest.as_str(), ), - Some(body), - ) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + ("explanation", value.explanation.as_str()), + ] { + if value.trim().is_empty() { + return Err(ToolError::InvalidArgument(format!( + "{name} must not be empty" + ))); + } + } + if value + .expected_closure_operation_key + .as_deref() + .is_some_and(|key| key.trim().is_empty()) + { + return Err(ToolError::InvalidArgument( + "expected_closure_operation_key must be null or nonempty".into(), + )); + } + match &value.action { + ticket::TicketCaseAction::Close { reason } + | ticket::TicketCaseAction::CorrectClosure { reason } + if matches!(reason, ticket::TicketClosureReason::Other(text) if text.trim().is_empty()) => + { + return Err(ToolError::InvalidArgument( + "other closure reason must not be empty".into(), + )); + } + _ => {} + } + let mut body = + serde_json::to_value(&value).map_err(|error| ToolError::Internal(error.to_string()))?; + body.as_object_mut() + .expect("typed Ticket input is an object") + .remove("ticket"); + // Actor identity is transport-bound, never a model-visible author/role input. + let ticket = WorkspaceHttpTicketBackend::new(self.client.clone()) + .update_case( + &value.ticket, + serde_json::from_value(body) + .map_err(|error| ToolError::InvalidArgument(error.to_string()))?, + ) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; Ok(ToolOutput { summary: self.kind.name().into(), content: Some( @@ -523,9 +485,14 @@ fn workspace_ticket_decision_definition( fn workspace_ticket_tool_description(name: &str) -> String { match name { - "CompleteTicket" => COMPLETE_TICKET_DESCRIPTION.into(), - "TicketWorkflowState" => TICKET_STATE_UPDATE_DESCRIPTION.into(), - "TicketClose" => TICKET_CLOSE_DESCRIPTION.into(), + "TicketCaseUpdate" => TICKET_CASE_UPDATE_DESCRIPTION.into(), + name if assignment_public::TOOL_NAMES.contains(&name) => { + assignment_public::description(name).into() + } + name if assignment_tracking::TOOL_NAMES.contains(&name) => { + assignment_tracking::description(name).into() + } + name if tags::TAG_TOOL_NAMES.contains(&name) => tags::description(name).into(), _ => ticket_tool_description(name, None), } } @@ -546,7 +513,7 @@ pub trait TicketService: Send + Sync { pub struct TicketHandoff { pub id: String, pub resource_key: String, - pub workflow_state: TicketWorkflowState, + pub workflow_state: TicketState, } fn is_canonical_ticket_resource_key(resource_key: &str) -> bool { @@ -655,7 +622,26 @@ impl TicketFeatureAccess { .iter() .copied() .filter(|name| self.allows_tool(name)) - .chain((self.authoring || self.workflow || self.thread).then_some("CompleteTicket")) + .chain(tags::TAG_TOOL_NAMES.iter().copied().filter(|name| { + *name == "WorkspaceTagList" || self.authoring || self.workflow || self.thread + })) + .chain( + assignment_tracking::TOOL_NAMES + .iter() + .copied() + .filter(|name| *name != "TicketAssignmentTrackingUpdate" || self.workflow), + ) + .chain( + assignment_public::TOOL_NAMES + .iter() + .copied() + .filter(|name| { + assignment_public::READ_TOOL_NAMES.contains(name) + || self.authoring + || self.workflow + || (*name == "TicketAssignmentAnswer" && self.thread) + }), + ) .collect() } @@ -664,30 +650,33 @@ impl TicketFeatureAccess { // Workspace transport lets the Backend validate the current assignment // or author/orchestrator permission on every decision, in both modes. READ_ONLY_TOOL_NAMES.contains(&name) - || (name == "TicketWorkflowState" && (self.authoring || self.thread || self.workflow)) + || (name == "TicketCaseUpdate" && (self.authoring || self.thread || self.workflow)) || (self.authoring && AUTHORING_TOOL_NAMES.contains(&name)) || (self.thread && THREAD_TOOL_NAMES.contains(&name)) - || (self.intake && INTAKE_TOOL_NAMES.contains(&name)) || (self.workflow && WORKFLOW_ADDITIONAL_TOOL_NAMES.contains(&name)) } } -const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"]; +const READ_ONLY_TOOL_NAMES: &[&str] = &[ + "QueryTicket", + "ShowTicket", + "WorkspaceTagList", + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", +]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", - "TicketMarkReady", - "TicketQueue", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", ]; const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"]; -const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"]; - #[cfg(test)] const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", @@ -695,13 +684,21 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "QueryTicket", "ShowTicket", "TicketComment", - "TicketMarkReady", - "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", - "CompleteTicket", + "WorkspaceTagList", + "WorkspaceTagCreate", + "WorkspaceTagRename", + "WorkspaceTagDelete", + "TicketTagsSet", + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", + "TicketAssignmentAnswer", ]; #[cfg(test)] @@ -709,19 +706,29 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "QueryTicket", "ShowTicket", "TicketComment", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", - "CompleteTicket", + "WorkspaceTagList", + "WorkspaceTagCreate", + "WorkspaceTagRename", + "WorkspaceTagDelete", + "TicketTagsSet", + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", + "TicketAssignmentTrackingUpdate", + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", + "TicketAssignmentAnswer", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", @@ -996,49 +1003,6 @@ impl WorkspaceHttpTicketBackend { Ok(TicketBackendOperationResult::Unit) } - fn legacy_state_request( - client: Arc, - base: &str, - id: &TicketIdOrSlug, - change: TicketStateChange, - ) -> TicketResult { - // Preserve the caller's original `from`: refreshing it from the snapshot - // would turn a stale transition into a successful write. - let expected_state = TicketWorkflowState::parse(&change.from).ok_or_else(|| { - TicketError::Conflict(format!("invalid Ticket from state: {}", change.from)) - })?; - let state = TicketWorkflowState::parse(&change.to).ok_or_else(|| { - TicketError::Conflict(format!("invalid Ticket to state: {}", change.to)) - })?; - let reason = [change.reason.as_str(), change.body.as_str()] - .into_iter() - .map(str::trim) - .filter(|part| !part.is_empty()) - .collect::>() - .join("\n\n"); - if reason.is_empty() { - return Err(TicketError::Conflict( - "Ticket state decision requires a reason".into(), - )); - } - let snapshot: Ticket = Self::request_unprojected( - client, - WorkspaceRequestMethod::Get, - format!("{base}/{}/record", Self::ticket_path(id)), - None, - )?; - serde_json::to_value(ticket::TicketStateUpdate { - operation_key: uuid::Uuid::now_v7().to_string(), - expected_content_digest: ticket::ticket_content_digest(&snapshot), - expected_state, - state, - reason: reason.into(), - references: change.references, - author: change.author, - }) - .map_err(|error| TicketError::Conflict(format!("serialize Ticket state decision: {error}"))) - } - fn invoke_client( client: Arc, workspace_id: String, @@ -1046,6 +1010,32 @@ impl WorkspaceHttpTicketBackend { ) -> TicketResult { let base = format!("/api/w/{workspace_id}/tickets"); match operation { + TicketBackendOperation::ListWorkspaceTags { request } => { + let mut endpoint = format!( + "/api/w/{workspace_id}/ticket-tags?limit={}", + request.limit.unwrap_or(200).min(200) + ); + if let Some(after) = request.after { + endpoint.push_str("&after="); + endpoint.push_str(&Self::ticket_path(&TicketIdOrSlug::Id(after))); + } + Self::request(client, WorkspaceRequestMethod::Get, endpoint, None) + .map(TicketBackendOperationResult::WorkspaceTags) + } + TicketBackendOperation::MutateWorkspaceTag { request } => Self::request( + client, + WorkspaceRequestMethod::Post, + format!("/api/w/{workspace_id}/ticket-tags"), + Some(json!(request)), + ) + .map(TicketBackendOperationResult::WorkspaceTag), + TicketBackendOperation::SetTicketTags { id, request } => Self::request( + client, + WorkspaceRequestMethod::Put, + format!("{base}/{}/tags", Self::ticket_path(&id)), + Some(json!(request)), + ) + .map(TicketBackendOperationResult::WorkspaceTags), TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from } => { let value = Self::request::( client, @@ -1093,6 +1083,11 @@ impl WorkspaceHttpTicketBackend { Ok(TicketBackendOperationResult::Ticket(ticket)) } TicketBackendOperation::Create { input } => { + if input.workflow_state == Some(TicketState::Closed) { + return Err(TicketError::Conflict( + "Tickets must be created open; closing requires a case judgment".into(), + )); + } let ticket = Self::request( client, WorkspaceRequestMethod::Post, @@ -1131,15 +1126,9 @@ impl WorkspaceHttpTicketBackend { TicketError::Conflict(format!("serialize Ticket event: {error}")) })?), ), - TicketBackendOperation::AddStateChanged { id, change } => { - let body = Self::legacy_state_request(client.clone(), &base, &id, change)?; - Self::request_unit( - client, - WorkspaceRequestMethod::Post, - format!("{base}/{}/state-changes", Self::ticket_path(&id)), - Some(body), - ) - } + TicketBackendOperation::AddStateChanged { .. } => Err(TicketError::Conflict( + "Case changes require an explicit TicketCaseUpdate judgment".into(), + )), TicketBackendOperation::AddIntakeSummary { id, summary } => Self::request_unit( client, WorkspaceRequestMethod::Post, @@ -1148,57 +1137,31 @@ impl WorkspaceHttpTicketBackend { TicketError::Conflict(format!("serialize Ticket intake summary: {error}")) })?), ), - TicketBackendOperation::SetStateField { id, field, change } => { - if field != "workflow_state" && field != "state" { - return Err(TicketError::Conflict(format!( - "unsupported Ticket state field: {field}" - ))); - } - let body = Self::legacy_state_request(client.clone(), &base, &id, change)?; - Self::request_unit( - client, + TicketBackendOperation::UpdateCase { ticket, request } => { + let mut body = serde_json::to_value(request).map_err(|error| { + TicketError::Conflict(format!("serialize Ticket case judgment: {error}")) + })?; + // Domain callers cannot supply HTTP actor authority either. + body.as_object_mut() + .expect("typed case update is an object") + .remove("author"); + let encoded_ticket = Self::ticket_path(&TicketIdOrSlug::from(ticket.as_str())); + let _: ticket::TicketSummary = Self::request( + client.clone(), WorkspaceRequestMethod::Post, - format!( - "{base}/{}/state-fields/{}", - Self::ticket_path(&id), - Self::ticket_path(&TicketIdOrSlug::Query(field)) - ), + format!("/workspaces/{workspace_id}/tickets/{encoded_ticket}/case"), Some(body), - ) - } - TicketBackendOperation::SetWorkflowState { id, change } => { - let body = Self::legacy_state_request(client.clone(), &base, &id, change)?; - Self::request_unit( + )?; + // The mutation receipt returns a bounded summary. Read the full + // current authority; never fabricate history from that summary. + Self::request( client, - WorkspaceRequestMethod::Post, - format!("{base}/{}/workflow-state", Self::ticket_path(&id)), - Some(body), + WorkspaceRequestMethod::Get, + format!("{base}/{encoded_ticket}/record"), + None, ) + .map(TicketBackendOperationResult::Ticket) } - TicketBackendOperation::UpdateState { ticket, request } => Self::request( - client, - WorkspaceRequestMethod::Post, - format!( - "{base}/{}/state-update", - Self::ticket_path(&TicketIdOrSlug::from(ticket.as_str())) - ), - Some(serde_json::to_value(request).map_err(|error| { - TicketError::Conflict(format!("serialize Ticket state update: {error}")) - })?), - ) - .map(TicketBackendOperationResult::Ticket), - TicketBackendOperation::Complete { ticket, request } => Self::request( - client, - WorkspaceRequestMethod::Post, - format!( - "{base}/{}/complete", - Self::ticket_path(&TicketIdOrSlug::from(ticket.as_str())) - ), - Some(serde_json::to_value(request).map_err(|error| { - TicketError::Conflict(format!("serialize Ticket completion: {error}")) - })?), - ) - .map(TicketBackendOperationResult::Ticket), TicketBackendOperation::MarkReady { id, request } => Self::request( client, WorkspaceRequestMethod::Post, @@ -1215,36 +1178,6 @@ impl WorkspaceHttpTicketBackend { None, ) .map(TicketBackendOperationResult::QueueOutcome), - TicketBackendOperation::Close { id, resolution } => { - if resolution.as_str().trim().is_empty() { - return Err(TicketError::Conflict( - "Ticket close requires a resolution".into(), - )); - } - let snapshot: Ticket = Self::request_unprojected( - client.clone(), - WorkspaceRequestMethod::Get, - format!("{base}/{}/record", Self::ticket_path(&id)), - None, - )?; - let body = serde_json::to_value(ticket::TicketCompletion { - operation_key: uuid::Uuid::now_v7().to_string(), - expected_content_digest: ticket::ticket_content_digest(&snapshot), - expected_state: snapshot.meta.workflow_state, - reason: resolution.as_str().to_owned(), - references: Vec::new(), - author: None, - }) - .map_err(|error| { - TicketError::Conflict(format!("serialize Ticket close: {error}")) - })?; - Self::request_unit( - client, - WorkspaceRequestMethod::Post, - format!("{base}/{}/workflow/close", Self::ticket_path(&id)), - Some(body), - ) - } TicketBackendOperation::AddTicketRelation { id, relation } => { let source_resource_key = Self::resolve_ticket_resource_key(client.clone(), &base, &id)?; @@ -1356,6 +1289,35 @@ macro_rules! expect_ticket_result { } impl TicketBackend for WorkspaceHttpTicketBackend { + fn list_workspace_tags( + &self, + request: ticket::TagListRequest, + ) -> TicketResult> { + expect_ticket_result!( + self.invoke(TicketBackendOperation::ListWorkspaceTags { request }), + TicketBackendOperationResult::WorkspaceTags + ) + } + fn mutate_workspace_tag( + &self, + request: ticket::WorkspaceTagMutation, + ) -> TicketResult { + expect_ticket_result!( + self.invoke(TicketBackendOperation::MutateWorkspaceTag { request }), + TicketBackendOperationResult::WorkspaceTag + ) + } + fn set_ticket_tags( + &self, + id: TicketIdOrSlug, + request: ticket::TicketTagSetRequest, + ) -> TicketResult> { + expect_ticket_result!( + self.invoke(TicketBackendOperation::SetTicketTags { id, request }), + TicketBackendOperationResult::WorkspaceTags + ) + } + fn default_intake_ready_state_change_body(&self, from: &str) -> String { match self.invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody { from: from.to_string(), @@ -1432,44 +1394,9 @@ impl TicketBackend for WorkspaceHttpTicketBackend { } } - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> TicketResult<()> { - match self.invoke(TicketBackendOperation::SetStateField { - id, - field: field.to_string(), - change, - })? { - TicketBackendOperationResult::Unit => Ok(()), - other => Err(TicketError::Conflict(format!( - "unexpected ticket backend response: {other:?}" - ))), - } - } - - fn set_workflow_state( - &self, - id: TicketIdOrSlug, - change: TicketStateChange, - ) -> TicketResult<()> { - match self.invoke(TicketBackendOperation::SetWorkflowState { id, change })? { - TicketBackendOperationResult::Unit => Ok(()), - other => Err(TicketError::Conflict(format!( - "unexpected ticket backend response: {other:?}" - ))), - } - } - - fn update_state( - &self, - ticket: &str, - request: ticket::TicketStateUpdate, - ) -> TicketResult { + fn update_case(&self, ticket: &str, request: ticket::TicketCaseUpdate) -> TicketResult { expect_ticket_result!( - self.invoke(TicketBackendOperation::UpdateState { + self.invoke(TicketBackendOperation::UpdateCase { ticket: ticket.to_string(), request }), @@ -1502,15 +1429,6 @@ impl TicketBackend for WorkspaceHttpTicketBackend { ) } - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> { - match self.invoke(TicketBackendOperation::Close { id, resolution })? { - TicketBackendOperationResult::Unit => Ok(()), - other => Err(TicketError::Conflict(format!( - "unexpected ticket backend response: {other:?}" - ))), - } - } - fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -1601,6 +1519,36 @@ struct NativeTicketOperation { fn native_ticket_operation(tool_name: &str) -> Option { let operation = match tool_name { + "WorkspaceTagList" => NativeTicketOperation { + operation: "tag_list", + surface: NativeTicketSurface::Collection, + identity_field: None, + mutating: false, + }, + "WorkspaceTagCreate" => NativeTicketOperation { + operation: "tag_create", + surface: NativeTicketSurface::Collection, + identity_field: None, + mutating: true, + }, + "WorkspaceTagRename" => NativeTicketOperation { + operation: "tag_rename", + surface: NativeTicketSurface::Collection, + identity_field: None, + mutating: true, + }, + "WorkspaceTagDelete" => NativeTicketOperation { + operation: "tag_delete", + surface: NativeTicketSurface::Collection, + identity_field: None, + mutating: true, + }, + "TicketTagsSet" => NativeTicketOperation { + operation: "tags_set", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: true, + }, "QueryTicket" => NativeTicketOperation { operation: "query", surface: NativeTicketSurface::Collection, @@ -1631,38 +1579,56 @@ fn native_ticket_operation(tool_name: &str) -> Option { identity_field: Some("ticket"), mutating: true, }, - "TicketMarkReady" => NativeTicketOperation { - operation: "mark_ready", + "TicketOrchestratorAssign" => NativeTicketOperation { + operation: "orchestrator_assign", surface: NativeTicketSurface::Item, identity_field: Some("ticket"), mutating: true, }, - "TicketIntakeReady" => NativeTicketOperation { - operation: "intake_ready", + "TicketOrchestratorClear" => NativeTicketOperation { + operation: "orchestrator_clear", surface: NativeTicketSurface::Item, identity_field: Some("ticket"), mutating: true, }, - "TicketQueue" => NativeTicketOperation { - operation: "queue", + "TicketAssignmentsQuery" => NativeTicketOperation { + operation: "assignments_query", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: false, + }, + "TicketAssignmentDiagnostics" => NativeTicketOperation { + operation: "assignment_diagnostics", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: false, + }, + "TicketAssignmentAnswer" => NativeTicketOperation { + operation: "assignment_answer", surface: NativeTicketSurface::Item, identity_field: Some("ticket"), mutating: true, }, - "TicketWorkflowState" => NativeTicketOperation { - operation: "transition", + "TicketAssignmentTrackingQuery" => NativeTicketOperation { + operation: "assignment_tracking_query", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: false, + }, + "TicketAssignmentTrackingHistory" => NativeTicketOperation { + operation: "assignment_tracking_history", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: false, + }, + "TicketAssignmentTrackingUpdate" => NativeTicketOperation { + operation: "assignment_tracking_update", surface: NativeTicketSurface::Item, identity_field: Some("ticket"), mutating: true, }, - "CompleteTicket" => NativeTicketOperation { - operation: "complete", - surface: NativeTicketSurface::Item, - identity_field: Some("ticket"), - mutating: true, - }, - "TicketClose" => NativeTicketOperation { - operation: "close", + "TicketCaseUpdate" => NativeTicketOperation { + operation: "case_update", surface: NativeTicketSurface::Item, identity_field: Some("ticket"), mutating: true, @@ -1731,23 +1697,44 @@ pub(crate) fn enabled_ticket_definitions( "ShowTicket" => { workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show) } - "TicketClose" => workspace_ticket_decision_definition( + "TicketCaseUpdate" => workspace_ticket_decision_definition( client.clone(), - WorkspaceTicketDecisionKind::Close, - ), - "TicketWorkflowState" => workspace_ticket_decision_definition( - client.clone(), - WorkspaceTicketDecisionKind::UpdateState, + WorkspaceTicketDecisionKind::CaseUpdate, ), _ => definition, }) }) .collect(); - if allowed.contains(&"CompleteTicket") { - definitions.push(workspace_ticket_decision_definition( - client, - WorkspaceTicketDecisionKind::Complete, - )); + for kind in [ + tags::TagToolKind::List, + tags::TagToolKind::Create, + tags::TagToolKind::Rename, + tags::TagToolKind::Delete, + tags::TagToolKind::Set, + ] { + if allowed.contains(&kind.name()) { + definitions.push(tags::definition(client.clone(), kind)); + } + } + for kind in [ + assignment_tracking::TrackingToolKind::Query, + assignment_tracking::TrackingToolKind::History, + assignment_tracking::TrackingToolKind::Update, + ] { + if allowed.contains(&kind.name()) { + definitions.push(assignment_tracking::definition(client.clone(), kind)); + } + } + for kind in [ + assignment_public::PublicAssignmentKind::Assign, + assignment_public::PublicAssignmentKind::Clear, + assignment_public::PublicAssignmentKind::Assignments, + assignment_public::PublicAssignmentKind::Diagnostics, + assignment_public::PublicAssignmentKind::Answer, + ] { + if allowed.contains(&kind.name()) { + definitions.push(assignment_public::definition(client.clone(), kind)); + } } definitions } @@ -1983,13 +1970,16 @@ impl WipOperationHandler for TicketItemWipHandler { )?; let output = execute_native_ticket_tool(tool, &self.permissions, input, context).await?; let response = ticket_tool_output_json(output)?; - record_ticket_observation( - &self.observations, - Some(&self.ticket_reference), - &response, - tool.projection.mutating, - ); - if tool.projection.mutating { + // Classification has its own membership CAS and never changes item content. + if operation != "tags_set" { + record_ticket_observation( + &self.observations, + Some(&self.ticket_reference), + &response, + tool.projection.mutating, + ); + } + if tool.projection.mutating && operation != "tags_set" { record_affected_ticket_mutations(&self.observations, &self.ticket_reference, &response); } Ok(WipOperationOutput::native( @@ -2228,15 +2218,6 @@ fn record_affected_ticket_mutations( { affected.insert(target.to_string()); } - if let Some(queued) = response.get("queued_tickets").and_then(Value::as_array) { - affected.extend( - queued - .iter() - .filter_map(Value::as_str) - .filter(|reference| is_ticket_route_reference(reference)) - .map(ToOwned::to_owned), - ); - } for reference in affected.difference(&subject_references) { record_ticket_observation(observations, Some(reference), &Value::Null, true); } @@ -2411,13 +2392,16 @@ mod tests { // inject mutation rejection independently of that successful read. if request.method == WorkspaceRequestMethod::Get { response.status = 200; + } else if response.status == 200 && request.path.ends_with("/case") { + let record: Value = serde_json::from_str(&response.body).unwrap(); + response.body = record["meta"].to_string(); } self.requests.lock().unwrap().push(request); Ok(response) } } - fn decision_client(status: u16, state: TicketWorkflowState) -> Arc { + fn decision_client(status: u16, state: TicketState) -> Arc { let temp = tempfile::tempdir().unwrap(); let db = ticket::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap(); @@ -2436,511 +2420,248 @@ mod tests { fn decision_input() -> Value { json!({ - "ticket": "T-718", "operation_key": "decision-1", - "expected_content_digest": "content_digest-1", "expected_state": "planning", - "reason": "No repository changes are needed" + "ticket": "T-739", "operation_key": "case-1", + "expected_content_digest": "digest-1", "expected_state": "open", + "expected_closure_operation_key": null, + "action": {"action": "close", "reason": "not_planned"}, + "explanation": "No repository changes are needed" }) } - fn close_input() -> Value { + #[test] + fn case_backend_sends_explicit_judgment_without_domain_author_claim() { + let client = decision_client(200, TicketState::Closed); + let backend = WorkspaceHttpTicketBackend::new(client.clone()); let mut input = decision_input(); - let reason = input.as_object_mut().unwrap().remove("reason").unwrap(); - input["resolution"] = reason; - input - } - - fn legacy_change() -> TicketStateChange { - serde_json::from_value(json!({ - "from": "planning", "to": "closed", "reason": "Decision", - "body": "Supporting explanation", "references": [], "author": "caller" - })) - .unwrap() - } - - #[test] - fn legacy_ticket_state_adapters_send_strong_cas_without_refreshing_stale_from() { - for endpoint in [ - "state-changes", - "workflow-state", - "state-fields/workflow_state", - "state-fields/state", - ] { - // The observed state is ready, deliberately different from change.from. - let client = decision_client(200, TicketWorkflowState::Ready); - let snapshot: Ticket = serde_json::from_str(&client.response.body).unwrap(); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - let id = TicketIdOrSlug::from("T-718"); - match endpoint { - "state-changes" => backend.add_state_changed(id, legacy_change()), - "workflow-state" => backend.set_workflow_state(id, legacy_change()), - "state-fields/workflow_state" => { - backend.set_state_field(id, "workflow_state", legacy_change()) - } - _ => backend.set_state_field(id, "state", legacy_change()), - } - .unwrap(); - let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 2); - assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); - assert_eq!(requests[0].path, "/api/w/workspace/tickets/T-718/record"); - assert_eq!(requests[1].method, WorkspaceRequestMethod::Post); - assert_eq!( - requests[1].path, - format!("/api/w/workspace/tickets/T-718/{endpoint}") - ); - let body: Value = serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); - let request: ticket::TicketStateUpdate = serde_json::from_value(body.clone()).unwrap(); - assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok()); - assert_eq!( - request.expected_content_digest, - ticket::ticket_content_digest(&snapshot) - ); - assert_eq!(request.expected_state, TicketWorkflowState::Planning); - assert_eq!(request.state, TicketWorkflowState::Closed); - assert_eq!(request.reason, "Decision\n\nSupporting explanation"); - assert_eq!(body["author"], "caller"); - assert_eq!(body["references"], json!([])); - assert!(body.get("from").is_none()); - assert!(body.get("to").is_none()); - } - } - - #[test] - fn legacy_ticket_state_adapter_parses_each_original_display_state() { - let client = decision_client(200, TicketWorkflowState::Closed); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - TicketWorkflowState::Done, - TicketWorkflowState::Closed, - ] { - let mut change = legacy_change(); - change.from = state.as_str().into(); - change.to = state.as_str().into(); - backend - .set_workflow_state(TicketIdOrSlug::from("T-718"), change) - .unwrap(); - let requests = client.requests.lock().unwrap(); - let request: ticket::TicketStateUpdate = - serde_json::from_str(requests.last().unwrap().body.as_deref().unwrap()).unwrap(); - assert_eq!(request.expected_state, state); - assert_eq!(request.state, state); - } - } - - #[test] - fn legacy_ticket_adapters_reject_invalid_fields_states_and_empty_reasons_before_dispatch() { - let client = decision_client(200, TicketWorkflowState::Planning); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - assert!( - backend - .set_state_field( - TicketIdOrSlug::from("T-718"), - "approval_state", - legacy_change() - ) - .is_err() - ); - for field in ["from", "to"] { - let mut change = legacy_change(); - if field == "from" { - change.from = "approved".into(); - } else { - change.to = "approved".into(); - } - assert!( - backend - .set_workflow_state(TicketIdOrSlug::from("T-718"), change) - .is_err() - ); - } - let mut change = legacy_change(); - change.reason = " ".into(); - change.body = " \n ".into(); - assert!( - backend - .add_state_changed(TicketIdOrSlug::from("T-718"), change) - .is_err() - ); - assert!( - backend - .close(TicketIdOrSlug::from("T-718"), " ".into()) - .is_err() - ); - assert!(client.requests.lock().unwrap().is_empty()); - } - - #[test] - fn legacy_ticket_close_snapshots_cas_and_generates_distinct_completion_keys() { - let client = decision_client(200, TicketWorkflowState::Ready); - let snapshot: Ticket = serde_json::from_str(&client.response.body).unwrap(); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - for _ in 0..2 { - backend - .close( - TicketIdOrSlug::from("T-718"), - "Resolved without repository changes".into(), - ) - .unwrap(); - } - let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 4); - let mut keys = BTreeSet::new(); - for pair in requests.chunks_exact(2) { - assert_eq!(pair[0].method, WorkspaceRequestMethod::Get); - assert_eq!(pair[0].path, "/api/w/workspace/tickets/T-718/record"); - assert_eq!(pair[1].method, WorkspaceRequestMethod::Post); - assert_eq!( - pair[1].path, - "/api/w/workspace/tickets/T-718/workflow/close" - ); - let request: ticket::TicketCompletion = - serde_json::from_str(pair[1].body.as_deref().unwrap()).unwrap(); - assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok()); - assert!(keys.insert(request.operation_key)); - assert_eq!( - request.expected_content_digest, - ticket::ticket_content_digest(&snapshot) - ); - assert_eq!(request.expected_state, TicketWorkflowState::Ready); - assert_eq!(request.reason, "Resolved without repository changes"); - assert!(request.references.is_empty()); - assert!(request.author.is_none()); - } - } - - #[test] - fn legacy_ticket_adapters_surface_backend_authority_and_stale_state_rejections() { - for status in [403, 409] { - for close in [false, true] { - let client = decision_client(status, TicketWorkflowState::Ready); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - let result = if close { - backend.close(TicketIdOrSlug::from("T-718"), "Resolved".into()) - } else { - backend.set_workflow_state(TicketIdOrSlug::from("T-718"), legacy_change()) - }; - assert!( - result - .unwrap_err() - .to_string() - .contains(&format!("HTTP status {status}")) - ); - assert_eq!(client.requests.lock().unwrap().len(), 2); - } - } - } - - #[tokio::test] - async fn native_ticket_close_binds_subject_and_forwards_strong_closed_state_decision() { - let client = decision_client(200, TicketWorkflowState::Closed); - let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring()) - .unwrap(); - let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); - let close = descriptor - .operations - .iter() - .find(|op| op.name == "close") - .unwrap(); - assert!( - !close - .parameters - .iter() - .any(|p| p.name == "ticket" || p.name == "author" || p.name == "role") - ); - for field in [ - "operation_key", - "expected_content_digest", - "expected_state", - "resolution", - ] { - assert!( - close - .parameters - .iter() - .any(|p| p.name == field && p.required) - ); - } - assert!( - close - .parameters - .iter() - .any(|p| p.name == "references" && !p.required) - ); - let observations = Arc::new(Mutex::new(TicketObservationState::default())); - let resolver = TicketItemResolver { - tools: operation_map(tools), - permissions: None, - collection_route: "/tickets".into(), - observations, - }; - record_ticket_observation( - &resolver.observations, - Some("T-718"), - &json!({"ticket": "T-718", "body": "observed requirements"}), - false, - ); - let bound = resolver.resolve("T-718").unwrap(); - let before = bound.object.validator; - let other = resolver.resolve("T-719").unwrap().object.validator; - let mut input = close_input(); input.as_object_mut().unwrap().remove("ticket"); - let arguments = input - .as_object() - .unwrap() - .iter() - .map(|(key, value)| (key.clone(), json_to_wip(value).unwrap())) - .collect(); - bound - .handler - .call( - "close", - &arguments, - WipCallContext { - execution: ToolExecutionContext::direct(), - security_context: "author".into(), - }, - ) - .await - .unwrap_or_else(|_| panic!("bound close should succeed")); - assert_ne!(before, resolver.resolve("T-718").unwrap().object.validator); - assert_eq!(other, resolver.resolve("T-719").unwrap().object.validator); + input["author"] = json!("self-reported-orchestrator"); + backend + .update_case("T-739", serde_json::from_value(input.clone()).unwrap()) + .unwrap(); let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].path, - "/api/w/workspace/tickets/T-718/state-update" - ); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].path, "/api/w/workspace/tickets/T-739/record"); + assert_eq!(requests[0].path, "/workspaces/workspace/tickets/T-739/case"); let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); - assert_eq!( - body, - json!({ - "operation_key": "decision-1", "expected_content_digest": "content_digest-1", - "expected_state": "planning", "state": "closed", - "reason": "No repository changes are needed", "references": [] - }) - ); + input.as_object_mut().unwrap().remove("author"); + input["references"] = json!([]); + assert_eq!(body, input); } - #[tokio::test] - async fn native_ticket_close_preserves_backend_rejections_and_wip_permission_gate() { - for (status, code) in [ - (403, ProtocolErrorCode::PermissionDenied), - (409, ProtocolErrorCode::InvalidArguments), - ] { - let client = decision_client(status, TicketWorkflowState::Planning); - let tools = - native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); - let tool = tools - .iter() - .find(|tool| tool.name == "TicketClose") - .unwrap(); - let error = execute_native_ticket_tool( - tool, - &None, - close_input(), - WipCallContext { - execution: ToolExecutionContext::direct(), - security_context: "self-reported-author".into(), - }, - ) - .await - .expect_err("Backend rejection must be preserved"); - assert!( - matches!(error, WipOperationError::Protocol(ProtocolError { code: actual, .. }) if actual == code) - ); - assert_eq!(client.requests.lock().unwrap().len(), 1); - } - let client = decision_client(200, TicketWorkflowState::Closed); - let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); - let tool = tools - .iter() - .find(|tool| tool.name == "TicketClose") - .unwrap(); - let permissions = Some(ToolPermissionConfig { - default_action: ToolPermissionAction::Deny, - rules: Vec::new(), - }); - let error = execute_native_ticket_tool( - tool, - &permissions, - close_input(), - WipCallContext { - execution: ToolExecutionContext::direct(), - security_context: "author".into(), - }, - ) - .await - .expect_err("WIP permission denial must prevent dispatch"); - assert!(matches!( - error, - WipOperationError::Protocol(ProtocolError { - code: ProtocolErrorCode::PermissionDenied, - .. - }) - )); + #[test] + fn case_backend_rejects_creation_closed_and_untyped_state_change_without_dispatch() { + let client = decision_client(200, TicketState::Open); + let backend = WorkspaceHttpTicketBackend::new(client.clone()); + let mut input = NewTicket::new("No bypass"); + input.workflow_state = Some(TicketState::Closed); + assert!( + backend + .create(input) + .unwrap_err() + .to_string() + .contains("created open") + ); + let change = TicketStateChange::new("open", "closed", "Decision", "Explanation"); + assert!( + backend + .add_state_changed(TicketIdOrSlug::from("T-739"), change) + .is_err() + ); assert!(client.requests.lock().unwrap().is_empty()); } - #[tokio::test] - async fn completion_accepts_mrless_decisions_and_forwards_cas_without_actor_claims() { - let client = decision_client(200, TicketWorkflowState::Done); - let (_, tool) = workspace_ticket_decision_definition( - client.clone(), - WorkspaceTicketDecisionKind::Complete, - )(); - let output = tool - .execute( - &decision_input().to_string(), - ToolExecutionContext::direct(), - ) - .await - .unwrap(); - let projected: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); - assert_eq!(projected["meta"]["id"], "T-718"); - assert_eq!(projected["meta"]["workflow_state"], "done"); - let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].path, "/api/w/workspace/tickets/T-718/complete"); - assert_eq!(requests[0].method, WorkspaceRequestMethod::Post); - let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); - assert_eq!( - body, - json!({ - "operation_key": "decision-1", "expected_content_digest": "content_digest-1", - "expected_state": "planning", "reason": "No repository changes are needed", "references": [] - }) - ); - } - - #[tokio::test] - async fn state_updates_forward_each_display_state_without_other_control_operations() { + #[test] + fn query_schema_allows_only_open_and_closed_case_states() { + let client = decision_client(200, TicketState::Open); + let (meta, _) = workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Query)(); + let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); + assert!(validator.is_valid(&json!({"states":["open", "closed"]}))); for state in [ "planning", "ready", "queued", + "inprogress", "in_progress", "done", - "closed", ] { - let client = decision_client(200, TicketWorkflowState::Planning); - let (_, tool) = workspace_ticket_decision_definition( - client.clone(), - WorkspaceTicketDecisionKind::UpdateState, - )(); - let mut input = decision_input(); - input["state"] = json!(state); - tool.execute(&input.to_string(), ToolExecutionContext::direct()) - .await - .unwrap(); - let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].path, - "/api/w/workspace/tickets/T-718/state-update" + assert!(!validator.is_valid(&json!({"states":[state]}))); + assert!( + serde_json::from_value::(json!({"states":[state]})) + .is_err() ); - let mut expected = input.as_object().unwrap().clone(); - expected.remove("ticket"); - expected.insert("references".into(), json!([])); - let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); - assert_eq!(body, Value::Object(expected)); } } #[tokio::test] - async fn decision_inputs_require_reason_cas_and_reject_self_reported_authority_before_dispatch() - { - for kind in [ - WorkspaceTicketDecisionKind::Complete, - WorkspaceTicketDecisionKind::UpdateState, - WorkspaceTicketDecisionKind::Close, + async fn case_update_forwards_all_judgments_and_replays_without_actor_or_repo_requirements() { + for (action, state, closure_key) in [ + ( + json!({"action":"close", "reason":"completed"}), + "open", + Value::Null, + ), + ( + json!({"action":"close", "reason":"not_planned"}), + "open", + Value::Null, + ), + ( + json!({"action":"close", "reason":"duplicate"}), + "open", + Value::Null, + ), + ( + json!({"action":"close", "reason":"unknown"}), + "open", + Value::Null, + ), + (json!({"action":"reopen"}), "closed", json!("closed-1")), + ( + json!({"action":"correct_closure", "reason":{"other":"Changed judgment"}}), + "closed", + json!("closed-1"), + ), ] { - let client = decision_client(200, TicketWorkflowState::Done); - let (meta, tool) = workspace_ticket_decision_definition(client.clone(), kind)(); - let mut valid = decision_input(); - if matches!(kind, WorkspaceTicketDecisionKind::UpdateState) { - valid["state"] = json!("ready"); + let client = decision_client(200, TicketState::Closed); + let (meta, tool) = workspace_ticket_decision_definition( + client.clone(), + WorkspaceTicketDecisionKind::CaseUpdate, + )(); + assert_eq!(meta.name, "TicketCaseUpdate"); + let mut input = decision_input(); + input["action"] = action; + input["expected_state"] = json!(state); + input["expected_closure_operation_key"] = closure_key; + input["references"] = json!([{"kind":"result", "target":"notes"}]); + assert!( + jsonschema::validator_for(&meta.input_schema) + .unwrap() + .is_valid(&input) + ); + for _ in 0..2 { + let output = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let projected: Value = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(projected["meta"]["workflow_state"], "closed"); } - let reason_field = if matches!(kind, WorkspaceTicketDecisionKind::Close) { - let reason = valid.as_object_mut().unwrap().remove("reason").unwrap(); - valid["resolution"] = reason; - "resolution" - } else { - "reason" - }; - let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); - assert!(validator.is_valid(&valid)); - for field in [ - "operation_key", - "expected_content_digest", - "expected_state", - reason_field, - ] { - let mut invalid = valid.clone(); - invalid.as_object_mut().unwrap().remove(field); - assert!(!validator.is_valid(&invalid)); - assert!( - tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) - .await - .is_err() - ); - } - for field in [ - reason_field, - "operation_key", - "expected_content_digest", - "ticket", - ] { - let mut invalid = valid.clone(); - invalid[field] = json!(" "); - assert!( - tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) - .await - .is_err() - ); - } - for field in [ - "role", - "author", - "merge_request_ids", - "requirement_approval_event_id", - ] { - let mut invalid = valid.clone(); - invalid[field] = json!("coder"); - assert!(!validator.is_valid(&invalid)); - assert!( - tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) - .await - .is_err() - ); + let mut expected = input.as_object().unwrap().clone(); + expected.remove("ticket"); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 4); + assert_eq!( + requests + .iter() + .filter(|r| r.method == WorkspaceRequestMethod::Get) + .count(), + 2 + ); + for request in requests + .iter() + .filter(|r| r.method == WorkspaceRequestMethod::Post) + { + assert_eq!(request.path, "/workspaces/workspace/tickets/T-739/case"); + assert_eq!(request.method, WorkspaceRequestMethod::Post); + let body: Value = serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + assert_eq!(body, Value::Object(expected.clone())); } + } + } + + #[tokio::test] + async fn case_update_requires_observed_nullable_closure_cas_and_rejects_malformed_or_actor_claims() + { + let client = decision_client(200, TicketState::Closed); + let (meta, tool) = workspace_ticket_decision_definition( + client.clone(), + WorkspaceTicketDecisionKind::CaseUpdate, + )(); + let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); + let valid = decision_input(); + assert!(validator.is_valid(&valid)); + for field in [ + "ticket", + "operation_key", + "expected_content_digest", + "expected_state", + "expected_closure_operation_key", + "action", + "explanation", + ] { let mut invalid = valid.clone(); - invalid["expected_state"] = json!("approved"); - assert!(!validator.is_valid(&invalid)); + invalid.as_object_mut().unwrap().remove(field); + assert!( + !validator.is_valid(&invalid), + "required schema field {field}" + ); + assert!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await + .is_err(), + "required decode field {field}" + ); + } + for (field, value) in [ + ("expected_state", json!("done")), + ("expected_state", json!("queued")), + ("expected_closure_operation_key", json!(42)), + ("action", json!({"action":"close"})), + ("action", json!({"action":"close", "reason":"success"})), + ("action", json!({"action":"reopen", "reason":"completed"})), + ("actor", json!("orchestrator")), + ("author", json!("coder")), + ("role", json!("coder")), + ("merge_request_ids", json!([])), + ] { + let mut invalid = valid.clone(); + invalid[field] = value; + assert!(!validator.is_valid(&invalid), "schema accepts {invalid}"); + assert!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await + .is_err(), + "decoder accepts {invalid}" + ); + } + for field in [ + "ticket", + "operation_key", + "expected_content_digest", + "explanation", + "expected_closure_operation_key", + ] { + let mut invalid = valid.clone(); + invalid[field] = json!(" "); assert!( tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) .await .is_err() ); - assert!(client.requests.lock().unwrap().is_empty()); } + let mut invalid = valid; + invalid["action"] = json!({"action":"close", "reason":{"other":" "}}); + assert!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await + .is_err() + ); + assert!(client.requests.lock().unwrap().is_empty()); } #[tokio::test] - async fn decision_tools_preserve_backend_assignment_and_cas_rejections() { + async fn case_update_preserves_backend_cas_authority_rejections_and_wip_permission_gate() { for (status, code) in [ (403, ProtocolErrorCode::PermissionDenied), (409, ProtocolErrorCode::InvalidArguments), ] { - let client = decision_client(status, TicketWorkflowState::Planning); + let client = decision_client(status, TicketState::Open); let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap(); let tool = tools .iter() - .find(|tool| tool.name == "CompleteTicket") + .find(|tool| tool.name == "TicketCaseUpdate") .unwrap(); let error = execute_native_ticket_tool( tool, @@ -2958,70 +2679,89 @@ mod tests { ); assert_eq!(client.requests.lock().unwrap().len(), 1); } - } - - #[test] - fn native_ticket_decision_descriptions_preserve_intent_and_independent_lifecycle() { - let tools = native_ticket_tools( - decision_client(200, TicketWorkflowState::Ready), - TicketFeatureAccess::work_report(), - ) - .unwrap(); - let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); - let complete = descriptor - .operations + let client = decision_client(200, TicketState::Closed); + let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); + let tool = tools .iter() - .find(|op| op.name == "complete") + .find(|tool| tool.name == "TicketCaseUpdate") .unwrap(); - let summary = &complete.documentation.as_ref().unwrap().summary; - assert!(summary.contains("Merge Requests and approvals are not prerequisites")); - assert!(summary.contains("return without conclusion need not call this tool")); - assert!(summary.contains("not real judgment")); - assert!(summary.contains("retaining terminal responsibility")); - assert!(summary.contains("does not grant review/merge authority or stop/remove Workers")); + let permissions = Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: Vec::new(), + }); + let error = execute_native_ticket_tool( + tool, + &permissions, + decision_input(), + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "author".into(), + }, + ) + .await + .expect_err("WIP denial prevents dispatch"); + assert!(matches!( + error, + WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + }) + )); + assert!(client.requests.lock().unwrap().is_empty()); } #[tokio::test] - async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() { - let client = decision_client(200, TicketWorkflowState::Done); - let observations = Arc::new(Mutex::new(TicketObservationState::default())); + async fn native_case_update_binds_subject_requires_closure_observation_and_stales_only_its_ticket() + { + let client = decision_client(200, TicketState::Closed); let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap(); let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); - let complete = descriptor + let case_update = descriptor .operations .iter() - .find(|op| op.name == "complete") + .find(|op| op.name == "case_update") .unwrap(); - assert!(!complete.parameters.iter().any(|p| p.name == "ticket")); + assert!( + !case_update + .parameters + .iter() + .any(|p| p.name == "ticket" || p.name == "actor" || p.name == "author") + ); for field in [ "operation_key", "expected_content_digest", "expected_state", - "reason", + "expected_closure_operation_key", + "action", + "explanation", ] { assert!( - complete + case_update .parameters .iter() .any(|p| p.name == field && p.required) ); } + let summary = &case_update.documentation.as_ref().unwrap().summary; + assert!(summary.contains("Closed does not imply success")); + assert!(summary.contains("not real judgment")); + assert!(summary.contains("Return without conclusion need not call this tool")); let resolver = TicketItemResolver { tools: operation_map(tools), permissions: None, collection_route: "/tickets".into(), - observations: observations.clone(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), }; record_ticket_observation( &resolver.observations, - Some("T-718"), - &json!({"ticket": "T-718", "body": "observed requirements"}), + Some("T-739"), + &json!({"ticket":"T-739", "body":"observed requirements"}), false, ); - let bound = resolver.resolve("T-718").unwrap(); + let bound = resolver.resolve("T-739").unwrap(); let before = bound.object.validator; - let other = resolver.resolve("T-719").unwrap().object.validator; + let other = resolver.resolve("T-740").unwrap().object.validator; let mut input = decision_input(); input.as_object_mut().unwrap().remove("ticket"); let arguments = input @@ -3033,7 +2773,7 @@ mod tests { bound .handler .call( - "complete", + "case_update", &arguments, WipCallContext { execution: ToolExecutionContext::direct(), @@ -3041,13 +2781,32 @@ mod tests { }, ) .await - .unwrap_or_else(|_| panic!("bound completion should succeed")); - assert_ne!(before, resolver.resolve("T-718").unwrap().object.validator); - assert_eq!(other, resolver.resolve("T-719").unwrap().object.validator); - assert_eq!( - client.requests.lock().unwrap()[0].path, - "/api/w/workspace/tickets/T-718/complete" - ); + .unwrap_or_else(|_| panic!("WIP case update rejected")); + assert_ne!(before, resolver.resolve("T-739").unwrap().object.validator); + assert_eq!(other, resolver.resolve("T-740").unwrap().object.validator); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].path, "/workspaces/workspace/tickets/T-739/case"); + assert_eq!(requests[1].path, "/api/w/workspace/tickets/T-739/record"); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + let mut expected = input; + expected["references"] = json!([]); + assert_eq!(body, expected); + } + + #[tokio::test] + async fn case_update_never_falls_back_without_workspace_authority() { + let client = crate::worker::unavailable_workspace_client(None, "no Workspace configured"); + let (_, tool) = + workspace_ticket_decision_definition(client, WorkspaceTicketDecisionKind::CaseUpdate)(); + let error = tool + .execute( + &decision_input().to_string(), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("Workspace identity")); } #[test] @@ -3064,20 +2823,10 @@ mod tests { .collect::>(); assert_eq!( authoring_operations, - [ - "create", - "edit", - "query", - "read", - "comment", - "mark_ready", - "queue", - "transition", - "close", - "record_relation", - "remove_relation", - "complete", - ] + WORKSPACE_AUTHORING_TOOL_NAMES + .iter() + .map(|name| native_ticket_operation(name).unwrap().operation) + .collect::>() ); let item = ticket_descriptor( &authoring @@ -3114,19 +2863,10 @@ mod tests { .iter() .map(|tool| tool.projection.operation) .collect::>(), - [ - "query", - "read", - "comment", - "transition", - "close", - "dependency_check", - "record_relation", - "remove_relation", - "record_orchestration_plan", - "query_orchestration_plans", - "complete", - ] + WORKFLOW_TOOL_NAMES + .iter() + .map(|name| native_ticket_operation(name).unwrap().operation) + .collect::>() ); assert_eq!( native_ticket_tools(client, TicketFeatureAccess::review()) @@ -3134,7 +2874,10 @@ mod tests { .iter() .map(|tool| tool.projection.operation) .collect::>(), - ["query", "read"] + READ_ONLY_TOOL_NAMES + .iter() + .map(|name| native_ticket_operation(name).unwrap().operation) + .collect::>() ); } @@ -3277,7 +3020,7 @@ mod tests { collection_route: "/tickets".into(), observations: Arc::clone(&observations), }; - let response = json!({"ticket": "T-42", "body": "actual content", "state": "planning"}); + let response = json!({"ticket": "T-42", "body": "actual content", "state": "open"}); assert!(resolver.resolve("T-42").unwrap().object.validator.is_none()); record_ticket_observation(&observations, Some("T-42"), &response, false); let observed = resolver.resolve("T-42").unwrap().object.validator; @@ -3298,7 +3041,7 @@ mod tests { } #[test] - fn related_ticket_mutations_invalidate_target_and_queue_observations() { + fn related_ticket_mutation_invalidates_inverse_target_but_not_unrelated_tickets() { let observations = Arc::new(Mutex::new(TicketObservationState::default())); for ticket in ["T-42", "T-43", "T-44"] { record_ticket_observation( @@ -3315,22 +3058,21 @@ mod tests { observations: Arc::clone(&observations), }; let target_v1 = resolver.resolve("T-43").unwrap().object.validator; - let queued_v1 = resolver.resolve("T-44").unwrap().object.validator; + let unrelated_v1 = resolver.resolve("T-44").unwrap().object.validator; record_affected_ticket_mutations( &observations, "T-42", &json!({ "ticket": "T-42", - "target": "T-43", - "queued_tickets": ["T-42", "T-44"] + "target": "T-43" }), ); assert_ne!( target_v1, resolver.resolve("T-43").unwrap().object.validator ); - assert_ne!( - queued_v1, + assert_eq!( + unrelated_v1, resolver.resolve("T-44").unwrap().object.validator ); } @@ -3356,7 +3098,7 @@ mod tests { )); assert!(matches!( map_ticket_tool_error(ToolError::Internal( - "TicketClose outcome unknown after mutation: HTTP status 404".into() + "TicketCaseUpdate outcome unknown after mutation: HTTP status 404".into() )), WipOperationError::OutcomeUnknown(_) )); @@ -3417,7 +3159,7 @@ mod tests { assert!(query.input_schema["properties"]["attention"].is_object()); assert!(query.input_schema["properties"]["cursor"].is_object()); let query_schema = serde_json::to_string(&query.input_schema).unwrap(); - assert!(query_schema.contains("done_not_closed")); + assert!(!query_schema.contains("done_not_closed")); assert!(query_schema.contains("request_changes")); assert!(query_schema.contains("created_desc")); assert!( @@ -3428,18 +3170,13 @@ mod tests { assert_eq!(show.name, "ShowTicket"); assert!(show.input_schema["properties"]["event_limit"].is_object()); let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); - assert_eq!(tool_names.len(), 12); - assert!( - tool_names.len() < 13, - "authoring catalog must stay below the prior broad catalog" - ); + assert_eq!(tool_names, WORKSPACE_AUTHORING_TOOL_NAMES); let workflow_names = TicketFeatureAccess::workflow().tool_names(); - assert_eq!(workflow_names.len(), 11); - assert!( - workflow_names.len() < 12, - "workflow catalog must stay below the prior broad catalog" + assert_eq!(workflow_names, WORKFLOW_TOOL_NAMES); + assert_eq!( + TicketFeatureAccess::review().tool_names(), + READ_ONLY_TOOL_NAMES ); - assert_eq!(TicketFeatureAccess::review().tool_names().len(), 2); assert!(tool_names.contains(&"QueryTicket")); assert!(tool_names.contains(&"ShowTicket")); assert!(!tool_names.contains(&"TicketList")); @@ -3505,8 +3242,8 @@ mod tests { .collect::>(); assert!(workspace_tools.contains(&"TicketCreate")); assert!(workspace_tools.contains(&"TicketEditItem")); - assert!(workspace_tools.contains(&"TicketQueue")); - assert!(workspace_tools.contains(&"TicketWorkflowState")); + assert!(!workspace_tools.contains(&"TicketQueue")); + assert!(workspace_tools.contains(&"TicketCaseUpdate")); let orchestration = workspace_feature(TicketFeatureAccess::workflow()); let orchestration_descriptor = orchestration.descriptor(); @@ -3515,7 +3252,7 @@ mod tests { .iter() .map(|tool| tool.name.as_str()) .collect::>(); - assert!(orchestration_tools.contains(&"TicketWorkflowState")); + assert!(orchestration_tools.contains(&"TicketCaseUpdate")); assert!(orchestration_tools.contains(&"TicketDependencyCheck")); assert!(orchestration_tools.contains(&"TicketRelationRecord")); assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord")); @@ -3530,8 +3267,7 @@ mod tests { .map(|tool| tool.name.as_str()) .collect::>(); assert!(work_report_tools.contains(&"TicketComment")); - assert!(work_report_tools.contains(&"CompleteTicket")); - assert!(work_report_tools.contains(&"TicketWorkflowState")); + assert!(work_report_tools.contains(&"TicketCaseUpdate")); let review = workspace_feature(TicketFeatureAccess::review()); let review_descriptor = review.descriptor(); @@ -3540,8 +3276,58 @@ mod tests { .iter() .map(|tool| tool.name.as_str()) .collect::>(); - assert!(!review_tools.contains(&"TicketWorkflowState")); - assert!(!review_tools.contains(&"CompleteTicket")); + assert!(!review_tools.contains(&"TicketCaseUpdate")); + } + + #[test] + fn normal_and_wip_catalogs_exclude_retired_ticket_workflow_operations() { + let client: Arc = Arc::new( + crate::worker::TestWorkspaceHttpClient::new("workspace", "http://unused"), + ); + let retired = [ + "TicketMarkReady", + "TicketIntakeReady", + "TicketQueue", + "TicketWorkflowState", + "CompleteTicket", + "TicketClose", + ]; + for access in [ + TicketFeatureAccess::workspace_authoring(), + TicketFeatureAccess::intake(), + TicketFeatureAccess::workflow(), + TicketFeatureAccess::work_report(), + TicketFeatureAccess::read_only(), + TicketFeatureAccess { + authoring: true, + thread: true, + intake: true, + workflow: true, + }, + ] { + let normal = enabled_ticket_definitions(client.clone(), access); + let native = native_ticket_tools(client.clone(), access).unwrap(); + for name in retired { + assert!( + normal.iter().all(|definition| definition().0.name != name), + "{name}" + ); + assert!(native.iter().all(|tool| tool.name != name), "{name}"); + assert!(native_ticket_operation(name).is_none(), "{name}"); + } + if access.authoring || access.thread || access.workflow { + assert!( + normal + .iter() + .any(|definition| definition().0.name == "TicketCaseUpdate") + ); + assert!( + native + .iter() + .any(|tool| tool.projection.operation == "case_update") + ); + } + } } #[test] @@ -3587,10 +3373,10 @@ mod tests { assert_eq!(installed, WORKSPACE_AUTHORING_TOOL_NAMES); assert!(installed.iter().any(|tool| *tool == "TicketCreate")); assert!(installed.iter().any(|tool| *tool == "TicketEditItem")); - assert!(installed.iter().any(|tool| *tool == "TicketQueue")); - assert!(installed.iter().any(|tool| *tool == "TicketMarkReady")); + assert!(!installed.iter().any(|tool| *tool == "TicketQueue")); + assert!(!installed.iter().any(|tool| *tool == "TicketMarkReady")); assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady")); - assert!(installed.iter().any(|tool| *tool == "TicketWorkflowState")); + assert!(installed.iter().any(|tool| *tool == "TicketCaseUpdate")); assert!( !installed .iter() @@ -3647,7 +3433,7 @@ mod tests { let error = backend .invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody { - from: "planning".to_string(), + from: "open".to_string(), }) .unwrap_err(); @@ -3837,7 +3623,7 @@ mod tests { let created = sqlite.create(NewTicket::new("Ticket handoff")).unwrap(); let mut ticket = sqlite.show(TicketIdOrSlug::Id(created.id.clone())).unwrap(); ticket.meta.resource_key = Some("T-548".to_string()); - ticket.meta.workflow_state = TicketWorkflowState::Queued; + ticket.meta.workflow_state = TicketState::Open; let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -3867,7 +3653,7 @@ mod tests { server.join().unwrap(); assert_eq!(handoff.id, created.id); assert_eq!(handoff.resource_key, "T-548"); - assert_eq!(handoff.workflow_state, TicketWorkflowState::Queued); + assert_eq!(handoff.workflow_state, TicketState::Open); } #[test] diff --git a/crates/worker/src/feature/builtin/ticket/assignment_public.rs b/crates/worker/src/feature/builtin/ticket/assignment_public.rs new file mode 100644 index 000000000..484e142d2 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/assignment_public.rs @@ -0,0 +1,351 @@ +//! Public delegation operations over shared host-bound typed Workspace forwarding. +//! Source/actor authorization and assignment/receipt authority remain Backend-owned. +//! +//! Assign targets responsibility, not Worker execution: the target principal is not the actor. +//! The nullable expected assignment must be explicitly observed, never omitted as unknown. +//! A new reissue creates a delegation; an exact retry retains its operation identity and full input. +//! Saved delegation plus notification failure is not failed Assign. Reread and reconcile stale or +//! unknown outcomes rather than silently generating a new operation key or using another transport. +//! +//! Answers continue waiting/inprogress against the same assignment/event CAS without reporting +//! progress; done needs explicit reissue. Retained assignments and sanitized diagnostics are reads, +//! not mutation authority. Delivery/start cursors are independent of handling history; Runtime +//! acceptance, Worker liveness, and the absence of diagnostics never attest model processing. +#[cfg(test)] +mod tests; +use super::*; +use crate::worker::WorkspaceServerOperation; + +pub(super) const TOOL_NAMES: &[&str] = &[ + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", + "TicketAssignmentAnswer", +]; +pub(super) const READ_TOOL_NAMES: &[&str] = + &["TicketAssignmentsQuery", "TicketAssignmentDiagnostics"]; + +#[derive(Clone, Copy)] +pub(super) enum PublicAssignmentKind { + Assign, + Clear, + Assignments, + Diagnostics, + Answer, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct AssignInput { + ticket: String, + /// Target principal, not the authenticated actor. Backend validates role/principal pairing. + principal: server_api::TicketAssignmentPrincipal, + /// Observed current assignment ID, or explicitly null for an unassigned Ticket. + #[serde(deserialize_with = "observed_assignment")] + expected_assignment_id: Option, + /// Stable identity of this Assign/reissue; exact retries keep the same full input. + operation_id: String, +} +fn observed_assignment<'de, D: serde::Deserializer<'de>>(d: D) -> Result, D::Error> { + Option::::deserialize(d) +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ClearInput { + ticket: String, + /// Observed exact current delegation to release, not Worker identity/status. + assignment_id: String, + operation_id: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct AssignmentsInput { + ticket: String, + #[serde(flatten)] + query: server_api::TicketAssignmentsQuery, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct DiagnosticsInput { + ticket: String, + #[serde(flatten)] + query: server_api::TicketAssignmentDiagnosticsQuery, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct AnswerInput { + ticket: String, + #[serde(flatten)] + request: server_api::TicketAssignmentAnswerRequest, +} + +impl PublicAssignmentKind { + pub(super) fn name(self) -> &'static str { + match self { + Self::Assign => TOOL_NAMES[0], + Self::Clear => TOOL_NAMES[1], + Self::Assignments => TOOL_NAMES[2], + Self::Diagnostics => TOOL_NAMES[3], + Self::Answer => TOOL_NAMES[4], + } + } + fn schema(self) -> Value { + match self { + Self::Assign => { + let mut schema = json!(schemars::schema_for!(AssignInput)); + let required = schema["required"] + .as_array_mut() + .expect("Assign object schema"); + if !required + .iter() + .any(|field| field == "expected_assignment_id") + { + required.push(json!("expected_assignment_id")); + } + schema + } + Self::Clear => json!(schemars::schema_for!(ClearInput)), + Self::Assignments => json!(schemars::schema_for!(AssignmentsInput)), + Self::Diagnostics => json!(schemars::schema_for!(DiagnosticsInput)), + Self::Answer => json!(schemars::schema_for!(AnswerInput)), + } + } +} +pub(super) fn description(name: &str) -> &'static str { + match name { + "TicketOrchestratorAssign" => { + "Explicitly delegate or reissue this Ticket to a target Orchestrator principal using the existing role-assignment API. Query current assignment first; provide observed expected_assignment_id (explicit null only when unassigned), target principal and stable operation_id. Assign persists initial waiting without ready/Queue. Explicit reissue after done creates a new row even for the same principal and preserves the old result. Exact retry keeps the same operation_id and full input; never automatically generate a new key after conflict or unknown outcome. Reread and reconcile stale responsibility. Saved Assign and notification failure are separate outcomes, not a reason to reassign. Backend authenticates the actor and validates role/principal; target principal is not an authority claim. This does not start/stop Workers, close the case, or authorize review/merge." + } + "TicketOrchestratorClear" => { + "Release the observed current Orchestrator delegation using assignment_id and stable operation_id. Query first; exact retry retains the same full input/key, stale conflict requires rereading, and unknown outcome is not automatic success/failure or a new operation. Preserve last handling/result and release history; do not turn release into done, Worker stop, work transfer or resource cleanup. Done/closed do not require clear. Actor authority is authenticated by Backend, never supplied by the model." + } + "TicketAssignmentsQuery" => { + "Read a bounded page of retained Orchestrator assignment identities for this Ticket, role/principal, handling and independent release/replacement history. Use after_assignment_id from the returned next_assignment_id, limit 1..100. Historical done/results are not new work or execution authority. Use current tracking query before a new mutation; a replay receipt is not a fresh observation." + } + "TicketAssignmentDiagnostics" => { + "Read bounded sanitized existing delivery/acceptance/start evidence for an assignment_id, with independent after_input_event_id and after_start_operation_id cursors (limit 1..100). Saved delegation, notification persistence, Runtime acceptance and actual processing are distinct; absent acknowledgement may mean unknown outcome, not failed Assign. History/unknown are not pending work. No rendered content or binding credentials are exposed. Never infer or update self-reported handling from these diagnostics, Worker liveness or MR state." + } + "TicketAssignmentAnswer" => { + "Append continuing input to the observed current waiting/inprogress assignment with assignment_id, expected_last_event_id, stable operation_key and bounded answer body. Query current tracking first. Answers continue the same row without automatic inprogress; done requires explicit new Assign/reissue instead. Backend authenticates/fences source, current assignment and event CAS, then saves input/audit/exact receipt atomically and notifies committed input. Exact retry retains full input/key; stale/cleared/replaced conflicts require rereading, and unknown outcome must not be retried as a fresh operation. No model actor/role claims, new assignment, case closure or Worker start." + } + _ => unreachable!("unknown public assignment tool"), + } +} +fn identity(name: &str, value: &str) -> Result<(), ToolError> { + if value.trim().is_empty() || value.len() > 1024 || value.chars().any(char::is_control) { + return Err(ToolError::InvalidArgument(format!( + "{name} must be nonempty, control-free and at most 1024 bytes" + ))); + } + Ok(()) +} +fn page(limit: Option) -> Result { + let limit = limit.unwrap_or(100); + if !(1..=100).contains(&limit) { + return Err(ToolError::InvalidArgument( + "limit must be between 1 and 100".into(), + )); + } + Ok(limit) +} +fn optional_identity(name: &str, value: &Option) -> Result<(), ToolError> { + value + .as_deref() + .map(|value| identity(name, value)) + .transpose() + .map(|_| ()) +} + +struct PublicAssignmentTool { + client: Arc, + kind: PublicAssignmentKind, +} +#[async_trait] +impl Tool for PublicAssignmentTool { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { + self.client + .workspace_id() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + ToolError::InvalidArgument( + "Assignment tools require Backend Workspace identity; no local fallback".into(), + ) + })?; + let parse = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string()); + let operation = match self.kind { + PublicAssignmentKind::Assign => { + let input: AssignInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + identity("operation_id", &input.operation_id)?; + optional_identity("expected_assignment_id", &input.expected_assignment_id)?; + match &input.principal { + server_api::TicketAssignmentPrincipal::User { account_id } => { + identity("account_id", account_id)? + } + server_api::TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } => { + identity("runtime_id", runtime_id)?; + identity("worker_id", worker_id)?; + } + server_api::TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => { + identity("agent_key", agent_key)? + } + } + WorkspaceServerOperation::TicketAssignmentSet { + ticket_id: input.ticket, + role: server_api::TicketAssignmentRole::Orchestrator, + request: server_api::SetTicketRoleAssignmentRequest { + orchestrator_assignment_id: None, + operation_id: input.operation_id, + principal: input.principal, + expected_assignment_id: input.expected_assignment_id, + workdir_bindings: vec![], + }, + } + } + PublicAssignmentKind::Clear => { + let input: ClearInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + identity("assignment_id", &input.assignment_id)?; + identity("operation_id", &input.operation_id)?; + WorkspaceServerOperation::TicketAssignmentClear { + ticket_id: input.ticket, + role: server_api::TicketAssignmentRole::Orchestrator, + query: server_api::ClearTicketRoleAssignmentQuery { + operation_id: Some(input.operation_id), + assignment_id: Some(input.assignment_id), + }, + } + } + PublicAssignmentKind::Assignments => { + let mut input: AssignmentsInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + optional_identity("after_assignment_id", &input.query.after_assignment_id)?; + input.query.limit = Some(page(input.query.limit)?); + WorkspaceServerOperation::TicketAssignmentsPage { + ticket_id: input.ticket, + query: input.query, + } + } + PublicAssignmentKind::Diagnostics => { + let mut input: DiagnosticsInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + identity("assignment_id", &input.query.assignment_id)?; + optional_identity("after_input_event_id", &input.query.after_input_event_id)?; + optional_identity( + "after_start_operation_id", + &input.query.after_start_operation_id, + )?; + input.query.limit = Some(page(input.query.limit)?); + WorkspaceServerOperation::TicketAssignmentDiagnostics { + ticket_id: input.ticket, + query: input.query, + } + } + PublicAssignmentKind::Answer => { + let input: AnswerInput = serde_json::from_str(input).map_err(parse)?; + identity("ticket", &input.ticket)?; + for (name, value) in [ + ("assignment_id", &input.request.0.assignment_id), + ( + "expected_last_event_id", + &input.request.0.expected_last_event_id, + ), + ("operation_key", &input.request.0.operation_key), + ] { + identity(name, value)?; + } + let body = &input.request.0.body; + if body.trim().is_empty() || body.len() > 16384 || body.contains('\0') { + return Err(ToolError::InvalidArgument( + "Answer body must be nonempty, at most 16384 bytes and without NUL".into(), + )); + } + WorkspaceServerOperation::TicketAssignmentAnswer { + ticket_id: input.ticket, + request: input.request, + } + } + }; + let response = self + .client + .execute_server_operation(operation) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if !response.is_success() { + return Err(ToolError::ExecutionFailed(format!( + "Assignment request failed with HTTP status {}: {}", + response.status, + response.body.chars().take(1024).collect::() + ))); + } + if response.body.len() > 1024 * 1024 { + return Err(ToolError::ExecutionFailed( + "Assignment response exceeds 1 MiB; request a smaller page".into(), + )); + } + // Decode the actual public DTO and serialize only its declared fields; no raw + // backend payload/credentials or alternative result authority reaches the model. + let decode = |error: serde_json::Error| { + ToolError::ExecutionFailed(format!( + "Invalid Backend assignment response (outcome unknown after dispatch): {error}" + )) + }; + let content = match self.kind { + PublicAssignmentKind::Assign | PublicAssignmentKind::Clear => serde_json::to_value( + serde_json::from_str::( + &response.body, + ) + .map_err(decode)?, + ), + PublicAssignmentKind::Assignments => { + let result: server_api::TicketAssignmentsPage = + serde_json::from_str(&response.body).map_err(decode)?; + if result.0.items.len() > 100 { + return Err(ToolError::ExecutionFailed( + "Assignment page exceeds 100 items".into(), + )); + } + serde_json::to_value(result) + } + PublicAssignmentKind::Diagnostics => { + let result: server_api::TicketAssignmentDiagnosticsPage = + serde_json::from_str(&response.body).map_err(decode)?; + if result.inputs.len() > 100 || result.starts.len() > 100 { + return Err(ToolError::ExecutionFailed( + "Diagnostic page exceeds 100 items".into(), + )); + } + serde_json::to_value(result) + } + PublicAssignmentKind::Answer => serde_json::to_value( + serde_json::from_str::(&response.body) + .map_err(decode)?, + ), + } + .map_err(|error| ToolError::Internal(error.to_string()))?; + Ok(ToolOutput { + summary: self.kind.name().into(), + content: Some(content.to_string()), + attachments: vec![], + }) + } +} +pub(super) fn definition( + client: Arc, + kind: PublicAssignmentKind, +) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new(kind.name()) + .description(description(kind.name())) + .input_schema(kind.schema()), + Arc::new(PublicAssignmentTool { + client: client.clone(), + kind, + }) as Arc, + ) + }) +} diff --git a/crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs b/crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs new file mode 100644 index 000000000..144a2c692 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs @@ -0,0 +1,598 @@ +use super::*; +use crate::worker::{WorkspaceClientError, WorkspaceRequest, WorkspaceResponse}; + +#[derive(Debug)] +struct Transport { + requests: Mutex>, + response: Result, + available: bool, +} +impl WorkspaceClient for Transport { + fn workspace_id(&self) -> Option<&str> { + self.available.then_some("workspace") + } + fn kind(&self) -> &str { + "assignment-test" + } + fn is_available(&self) -> bool { + self.available + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + self.response.clone().map_err(WorkspaceClientError::Request) + } +} +fn transport(status: u16, body: Value) -> Arc { + Arc::new(Transport { + requests: Mutex::new(vec![]), + available: true, + response: Ok(WorkspaceResponse { + status, + body: body.to_string(), + }), + }) +} +fn cases() -> Vec<(PublicAssignmentKind, Value, Value)> { + vec![ + ( + PublicAssignmentKind::Assign, + json!({"ticket":"T-743", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}, "expected_assignment_id":null, "operation_id":"assign-1"}), + json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}), + ), + ( + PublicAssignmentKind::Clear, + json!({"ticket":"T-743", "assignment_id":"delegation", "operation_id":"clear-1"}), + json!({"workspace_id":"workspace", "ticket_id":"T-743", "assignment":null}), + ), + ( + PublicAssignmentKind::Assignments, + json!({"ticket":"T-743", "after_assignment_id":"old/delegation?", "limit":10}), + json!({"items":[], "next_assignment_id":null}), + ), + ( + PublicAssignmentKind::Diagnostics, + json!({"ticket":"T-743", "assignment_id":"delegation", "after_input_event_id":"ticket-event:7", "after_start_operation_id":"start/7", "limit":10}), + json!({"assignment_id":"delegation", "current":true, "input_origin_known":true, "actionable":true, "inputs":[], "next_input_event_id":null, "starts":[], "next_start_operation_id":null}), + ), + ( + PublicAssignmentKind::Answer, + json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"waiting", "operation_key":"answer-1", "body":"Proceed with option A"}), + json!({"assignment_id":"delegation", "input_event_id":"ticket-event:8", "event_index":8}), + ), + ] +} +#[tokio::test] +async fn public_assignment_schemas_require_observations_and_exclude_model_actor_claims() { + for (kind, input, response) in cases() { + let client = transport(200, response); + let (meta, tool) = definition(client.clone(), kind)(); + let schema = jsonschema::validator_for(&meta.input_schema).unwrap(); + assert!( + schema.is_valid(&input), + "{}: {}", + meta.name, + meta.input_schema + ); + for field in meta.input_schema["required"] + .as_array() + .into_iter() + .flatten() + { + let mut missing = input.clone(); + missing + .as_object_mut() + .unwrap() + .remove(field.as_str().unwrap()); + assert!(!schema.is_valid(&missing), "{} missing {field}", meta.name); + assert!(matches!( + tool.execute(&missing.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + for actor_claim in [ + "actor", + "author", + "role", + "runtime_id", + "worker_id", + "authentication_binding_id", + ] { + let mut forged = input.clone(); + forged[actor_claim] = json!("authority"); + assert!( + !schema.is_valid(&forged), + "{} accepts {actor_claim}", + meta.name + ); + assert!(matches!( + tool.execute(&forged.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + assert!(client.requests.lock().unwrap().is_empty()); + } +} +#[tokio::test] +async fn assign_reissue_and_clear_forward_observed_identity_and_exact_retry_without_new_key() { + for (kind, mut input, response) in cases().into_iter().filter(|(kind, _, _)| { + matches!( + kind, + PublicAssignmentKind::Assign | PublicAssignmentKind::Clear + ) + }) { + if matches!(kind, PublicAssignmentKind::Assign) { + input["expected_assignment_id"] = json!("previous-done"); + } + let client = transport(200, response); + let (_, tool) = definition(client.clone(), kind)(); + for _ in 0..2 { + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + } + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0], requests[1]); + assert!( + requests[0] + .path + .starts_with("/api/w/workspace/tickets/T-743/assignments/orchestrator") + ); + match kind { + PublicAssignmentKind::Assign => { + assert_eq!(requests[0].method, WorkspaceRequestMethod::Put); + let body: Value = + serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["expected_assignment_id"], "previous-done"); + assert_eq!(body["operation_id"], input["operation_id"]); + assert_eq!(body["principal"], input["principal"]); + assert!(body.get("actor").is_none()); + assert!(body.get("orchestrator_assignment_id").is_none()); + assert_eq!(body["workdir_bindings"], json!([])); + } + PublicAssignmentKind::Clear => { + assert_eq!(requests[0].method, WorkspaceRequestMethod::Delete); + assert!(requests[0].path.contains("assignment_id=delegation")); + assert!(requests[0].path.contains("operation_id=clear-1")); + assert!(requests[0].body.is_none()); + } + _ => unreachable!(), + } + } +} +#[tokio::test] +async fn continuing_answer_forwards_same_assignment_event_cas_and_saved_receipt() { + let (kind, input, response) = cases().pop().unwrap(); + let client = transport(200, response.clone()); + let (_, tool) = definition(client.clone(), kind)(); + for _ in 0..2 { + let output = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + assert_eq!( + serde_json::from_str::(output.content.as_deref().unwrap()).unwrap(), + response + ); + } + let requests = client.requests.lock().unwrap(); + assert_eq!(requests[0], requests[1]); + assert_eq!( + requests[0].path, + "/workspaces/workspace/tickets/T-743/assignment-answer" + ); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + let mut expected = input; + expected.as_object_mut().unwrap().remove("ticket"); + assert_eq!(body, expected); + assert!(body.get("tracking").is_none()); +} +#[tokio::test] +async fn assignment_pages_forward_independent_encoded_cursors_and_reject_unbounded_input() { + for (kind, input, response) in cases().into_iter().filter(|(kind, _, _)| { + matches!( + kind, + PublicAssignmentKind::Assignments | PublicAssignmentKind::Diagnostics + ) + }) { + let client = transport(200, response); + let (_, tool) = definition(client.clone(), kind)(); + for limit in [0, 101] { + let mut invalid = input.clone(); + invalid["limit"] = json!(limit); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); + assert!(requests[0].path.contains("limit=10")); + match kind { + PublicAssignmentKind::Assignments => assert!( + requests[0] + .path + .contains("after_assignment_id=old%2Fdelegation%3F") + ), + PublicAssignmentKind::Diagnostics => { + assert!( + requests[0] + .path + .contains("after_input_event_id=ticket-event%3A7") + ); + assert!( + requests[0] + .path + .contains("after_start_operation_id=start%2F7") + ); + } + _ => unreachable!(), + } + } +} +#[tokio::test] +async fn public_assignment_rejections_and_unknown_outcomes_never_auto_retry_or_replace_identity() { + for (kind, input, _) in cases() { + for response in [ + Ok(WorkspaceResponse { + status: 403, + body: "permission denied".into(), + }), + Ok(WorkspaceResponse { + status: 409, + body: "stale assignment or operation fingerprint mismatch".into(), + }), + Ok(WorkspaceResponse { + status: 200, + body: "invalid response after dispatch".into(), + }), + Err("response lost; outcome unknown".into()), + ] { + let client = Arc::new(Transport { + requests: Mutex::new(vec![]), + available: true, + response, + }); + let (_, tool) = definition(client.clone(), kind)(); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .is_err() + ); + assert_eq!( + client.requests.lock().unwrap().len(), + 1, + "{} retried", + kind.name() + ); + } + let client = Arc::new(Transport { + requests: Mutex::new(vec![]), + available: false, + response: Err("unavailable".into()), + }); + let (_, tool) = definition(client.clone(), kind)(); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap_err() + .to_string() + .contains("Backend Workspace identity") + ); + assert!(client.requests.lock().unwrap().is_empty()); + } +} +#[tokio::test] +async fn diagnostics_strip_undeclared_content_and_preserve_unknown_processing() { + let (kind, input, mut response) = cases().remove(3); + response["rendered_content"] = json!("must not reach model"); + response["authentication_binding_id"] = json!("must not reach model"); + response["inputs"] = json!([{"input_event_id":"ticket-event:7", "delivery_id":null, "notification":"not_reserved", "acceptance_outcome_unknown":true, "rendered_content":"hidden"}]); + response["starts"] = json!([{"operation_id":"start", "claim_state":"reserved", "assignment_id":null, "runtime_id":null, "worker_id":null, "actual_processing_unknown":true, "authentication_binding_id":"hidden"}]); + let (_, tool) = definition(transport(200, response), kind)(); + let output = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let output: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert!(output.get("rendered_content").is_none()); + assert!(output.get("authentication_binding_id").is_none()); + assert_eq!(output["inputs"][0]["acceptance_outcome_unknown"], true); + assert!(output["inputs"][0].get("rendered_content").is_none()); + assert_eq!(output["starts"][0]["actual_processing_unknown"], true); + assert!( + output["starts"][0] + .get("authentication_binding_id") + .is_none() + ); +} +#[test] +fn public_assignment_normal_and_wip_access_excludes_mutations_for_read_only_and_review() { + for access in [ + TicketFeatureAccess::read_only(), + TicketFeatureAccess::review(), + ] { + let client = transport(200, json!({})); + let normal = enabled_ticket_definitions(client.clone(), access); + let native = native_ticket_tools(client, access).unwrap(); + for name in READ_TOOL_NAMES { + assert!(normal.iter().any(|d| d().0.name == *name)); + assert!(native.iter().any(|t| t.name == *name)); + } + for name in [ + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentAnswer", + ] { + assert!(!normal.iter().any(|d| d().0.name == name)); + assert!(!native.iter().any(|t| t.name == name)); + } + } + for name in TOOL_NAMES { + let native = native_ticket_operation(name).unwrap(); + assert_eq!(native.identity_field, Some("ticket")); + assert_eq!(native.mutating, !READ_TOOL_NAMES.contains(name)); + } +} +#[tokio::test] +async fn ticket_query_closure_reason_is_typed_and_not_a_classification_tag() { + let client = transport( + 200, + json!({"items":[],"page":{"has_more":false,"next_cursor":null}}), + ); + let (meta, tool) = + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)(); + let schema = jsonschema::validator_for(&meta.input_schema).unwrap(); + let input = json!({"states":["closed"],"closure_reason":"completed","tag_ids":["tag-bug"]}); + assert!(schema.is_valid(&input)); + let invalid = json!({"closure_reason":"bug"}); + assert!(!schema.is_valid(&invalid)); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["closure_reason"], "completed"); + assert_eq!(body["tag_ids"], json!(["tag-bug"])); +} + +#[tokio::test] +async fn ticket_query_forwards_current_principal_and_self_reported_handling_filters() { + let client = transport( + 200, + json!({"items":[],"page":{"has_more":false,"next_cursor":null}}), + ); + let (meta, tool) = + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)(); + let schema = jsonschema::validator_for(&meta.input_schema).unwrap(); + let input = json!({"assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"handling_status":"waiting","states":["open"],"limit":5}); + assert!(schema.is_valid(&input)); + for status in ["running", "idle", "stopped", "closed"] { + let mut invalid = input.clone(); + invalid["handling_status"] = json!(status); + assert!(!schema.is_valid(&invalid)); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!(body["assigned_principal"], input["assigned_principal"]); + assert_eq!(body["handling_status"], "waiting"); + assert_eq!(body["states"], json!(["open"])); +} + +#[test] +fn resolved_builtin_profiles_expose_assignment_capabilities_without_repo_or_mr_prerequisites() { + let dir = tempfile::tempdir().unwrap(); + for profile in [ + "intake", + "orchestrator", + "coder", + "ticket-worker", + "reviewer", + ] { + let resolved = manifest::ProfileResolver::new() + .with_workspace_base(dir.path()) + .resolve( + &manifest::ProfileSelector::parse_cli(&format!("builtin:{profile}")), + manifest::ProfileResolveOptions::with_worker_name("assignment-fixture"), + ) + .unwrap(); + let config = resolved.manifest.feature.ticket; + assert!(config.enabled, "{profile}"); + let delegates = config.authoring || config.workflow; + let answers = delegates || config.thread; + let reports = config.workflow; + let access = TicketFeatureAccess { + authoring: config.authoring, + thread: config.thread, + intake: config.intake, + workflow: config.workflow, + }; + let names = access.tool_names(); + assert_eq!( + names.contains(&"TicketOrchestratorAssign"), + delegates, + "{profile}" + ); + assert_eq!( + names.contains(&"TicketOrchestratorClear"), + delegates, + "{profile}" + ); + assert_eq!( + names.contains(&"TicketAssignmentAnswer"), + answers, + "{profile}" + ); + assert_eq!( + names.contains(&"TicketAssignmentTrackingUpdate"), + reports, + "{profile}" + ); + assert!( + names.contains(&"TicketAssignmentsQuery") + && names.contains(&"TicketAssignmentDiagnostics"), + "{profile}" + ); + assert!( + !names.iter().any(|name| matches!( + *name, + "TicketQueue" | "TicketMarkReady" | "TicketIntakeReady" + )), + "{profile}" + ); + } +} + +#[tokio::test] +async fn invalid_answer_body_and_assignment_identities_fail_before_dispatch() { + let (kind, input, response) = cases().pop().unwrap(); + let client = transport(200, response); + let (_, tool) = definition(client.clone(), kind)(); + for body in [ + String::new(), + " ".into(), + "x".repeat(16385), + "text\0hidden".into(), + ] { + let mut invalid = input.clone(); + invalid["body"] = json!(body); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + for field in ["assignment_id", "expected_last_event_id", "operation_key"] { + for value in [String::new(), "x\n".into(), "x".repeat(1025)] { + let mut invalid = input.clone(); + invalid[field] = json!(value); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + } + assert!(client.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn assignment_wip_binds_ticket_and_preserves_normal_permission_and_cas_inputs() { + for (kind, mut input, response) in cases() { + let client = transport(200, response); + let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); + let handler = TicketItemWipHandler { + tools: operation_map( + tools + .into_iter() + .filter(|t| t.projection.surface == NativeTicketSurface::Item) + .collect(), + ), + permissions: None, + ticket_reference: "T-743".into(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), + }; + input.as_object_mut().unwrap().remove("ticket"); + let args: BTreeMap<_, _> = input + .as_object() + .unwrap() + .iter() + .map(|(key, value)| (key.clone(), json_to_wip(value).unwrap())) + .collect(); + let operation = native_ticket_operation(kind.name()).unwrap().operation; + handler + .call( + operation, + &args, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into(), + }, + ) + .await + .unwrap_or_else(|_| panic!("WIP {operation} rejected")); + assert_eq!(client.requests.lock().unwrap().len(), 1); + let mut forged = args.clone(); + forged.insert("ticket".into(), WipValue::String("T-other".into())); + assert!( + handler + .call( + operation, + &forged, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into() + } + ) + .await + .is_err() + ); + assert_eq!(client.requests.lock().unwrap().len(), 1); + } + let (_, input, response) = cases().pop().unwrap(); + let client = transport(200, response); + let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); + let denied = TicketItemWipHandler { + tools: operation_map( + tools + .into_iter() + .filter(|t| t.projection.surface == NativeTicketSurface::Item) + .collect(), + ), + permissions: Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: vec![], + }), + ticket_reference: "T-743".into(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), + }; + let args = input + .as_object() + .unwrap() + .iter() + .filter(|(k, _)| k.as_str() != "ticket") + .map(|(k, v)| (k.clone(), json_to_wip(v).unwrap())) + .collect(); + assert!(matches!( + denied + .call( + "assignment_answer", + &args, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into() + } + ) + .await, + Err(WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + })) + )); + assert!(client.requests.lock().unwrap().is_empty()); +} diff --git a/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs b/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs new file mode 100644 index 000000000..11eab85b7 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs @@ -0,0 +1,471 @@ +//! Self-reported handling of one immutable delegation. This transport never +//! supplies actor/role authority or converts tracking into execution lifecycle. +//! +//! Updates forward the complete handling payload with the observed event of that exact assignment, +//! not a status-only comparison. A replayed receipt is not a fresh observation for the next update. +//! Query/history may inspect retained assignments and unknown legacy handling; read access does not +//! enable self-report mutation. Unknown handling is not inferred waiting or successful done. +use super::*; + +pub(super) const TOOL_NAMES: &[&str] = &[ + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", + "TicketAssignmentTrackingUpdate", +]; + +#[derive(Clone, Copy)] +pub(super) enum TrackingToolKind { + Query, + History, + Update, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct QueryInput { + /// Ticket reference, normally T-*. + ticket: String, + /// Omit for the current delegation; set to inspect a retained assignment and release. + assignment_id: Option, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct HistoryInput { + ticket: String, + /// Immutable delegation identity, including released or replaced assignments. + assignment_id: String, + /// Continue after an event identity returned by this assignment's history. + after_event_id: Option, + #[schemars(range(min = 1, max = 100))] + limit: Option, +} + +#[derive(Serialize, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct UpdateInput { + ticket: String, + assignment_id: String, + /// Last event observed for this exact delegation, not Ticket status or Worker state. + expected_last_event_id: String, + /// Reuse the same key and complete input after an uncertain result. + operation_key: String, + tracking: ticket::TicketAssignmentTracking, +} + +impl TrackingToolKind { + pub(super) fn name(self) -> &'static str { + match self { + Self::Query => TOOL_NAMES[0], + Self::History => TOOL_NAMES[1], + Self::Update => TOOL_NAMES[2], + } + } + fn schema(self) -> Value { + match self { + Self::Query => json!(schemars::schema_for!(QueryInput)), + Self::History => json!(schemars::schema_for!(HistoryInput)), + Self::Update => json!(schemars::schema_for!(UpdateInput)), + } + } +} + +pub(super) fn description(name: &str) -> &'static str { + match name { + "TicketAssignmentTrackingQuery" => { + "Read the current Ticket delegation, or one retained assignment_id with its role/principal, Assign actor/time and separate release/replacement record, and durable self-reported waiting/inprogress/done, last event identity, waiting reason, progress, next action and result. Tracking is independent of Ticket open/closed, Worker running/idle/stop, MR and delivery state." + } + "TicketAssignmentTrackingHistory" => { + "Read bounded immutable history for one assignment_id, including released/replaced delegations. Continue with the returned event identity. Historical results do not grant execution rights or create a new request." + } + "TicketAssignmentTrackingUpdate" => { + "Record the current Workspace Orchestrator's self-reported handling of one delegation with assignment_id, observed last_event_id CAS and an exact-retry operation key. Direct waiting-to-done and post-close result recording are allowed. Answers during waiting continue the same row; explicit reissue after done requires a new Assign row, not overwriting the previous result. Send the complete tracking payload. Exact retry uses the same operation_key and full input; different input with that key is rejected. On CAS/stale conflict reread current authority and reconcile; unknown outcome is not success or failure and must not become a fresh operation automatically. Backend authenticates the current registered Orchestrator; model role names never authorize. This neither closes/reopens the Ticket, releases actual work, restores grants, starts/stops Workers nor approves/merges MRs. Released/replaced assignments reject new updates; exact retry receipts are not fresh observations." + } + _ => unreachable!("unknown assignment tracking tool"), + } +} + +fn required(name: &str, value: &str) -> Result<(), ToolError> { + if value.trim().is_empty() { + return Err(ToolError::InvalidArgument(format!( + "{name} must not be empty" + ))); + } + Ok(()) +} + +fn encode_segment(value: &str) -> String { + value + .bytes() + .map(|byte| { + if byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.' | b'~') { + char::from(byte).to_string() + } else { + format!("%{byte:02X}") + } + }) + .collect() +} + +#[derive(Clone)] +struct TrackingTool { + client: Arc, + kind: TrackingToolKind, +} + +#[async_trait] +impl Tool for TrackingTool { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { + let workspace = self + .client + .workspace_id() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + ToolError::InvalidArgument( + "Assignment tracking requires Backend Workspace identity; no local fallback" + .into(), + ) + })?; + let prefix = format!("/workspaces/{}/tickets", encode_segment(workspace)); + let parse_error = |error: serde_json::Error| ToolError::InvalidArgument(error.to_string()); + let (method, path, body) = match self.kind { + TrackingToolKind::Query => { + let input: QueryInput = serde_json::from_str(input).map_err(parse_error)?; + required("ticket", &input.ticket)?; + let mut path = format!( + "{prefix}/{}/assignment-tracking", + encode_segment(&input.ticket) + ); + if let Some(assignment_id) = input.assignment_id { + required("assignment_id", &assignment_id)?; + path.push_str(&format!( + "?assignment_id={}", + encode_segment(&assignment_id) + )); + } + (WorkspaceRequestMethod::Get, path, None) + } + TrackingToolKind::History => { + let input: HistoryInput = serde_json::from_str(input).map_err(parse_error)?; + required("ticket", &input.ticket)?; + required("assignment_id", &input.assignment_id)?; + let limit = input.limit.unwrap_or(100); + if !(1..=100).contains(&limit) { + return Err(ToolError::InvalidArgument( + "limit must be between 1 and 100".into(), + )); + } + let mut path = format!( + "{prefix}/{}/assignment-tracking/history?assignment_id={}&limit={limit}", + encode_segment(&input.ticket), + encode_segment(&input.assignment_id) + ); + if let Some(after) = input.after_event_id { + required("after_event_id", &after)?; + path.push_str(&format!("&after_event_id={}", encode_segment(&after))); + } + (WorkspaceRequestMethod::Get, path, None) + } + TrackingToolKind::Update => { + let input: UpdateInput = serde_json::from_str(input).map_err(parse_error)?; + for (name, value) in [ + ("ticket", &input.ticket), + ("assignment_id", &input.assignment_id), + ("expected_last_event_id", &input.expected_last_event_id), + ("operation_key", &input.operation_key), + ] { + required(name, value)?; + } + // Unknown fields (including author, role, runtime and worker) are + // rejected before transport; authenticated context belongs to Backend. + let body = json!({"assignment_id": input.assignment_id, "expected_last_event_id": input.expected_last_event_id, "operation_key": input.operation_key, "tracking": input.tracking}); + ( + WorkspaceRequestMethod::Post, + format!( + "{prefix}/{}/assignment-tracking", + encode_segment(&input.ticket) + ), + Some(body.to_string()), + ) + } + }; + let request = match body { + Some(body) => WorkspaceRequest::json(method, path, body), + None => WorkspaceRequest::get(path), + }; + let response = self + .client + .execute(request) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if !response.is_success() { + let detail: String = response.body.chars().take(1024).collect(); + return Err(ToolError::ExecutionFailed(format!( + "Assignment tracking request failed with HTTP status {}: {}", + response.status, detail + ))); + } + if response.body.len() > 1024 * 1024 { + return Err(ToolError::ExecutionFailed( + "Assignment tracking response exceeds 1 MiB; request a smaller history page".into(), + )); + } + let content: Value = serde_json::from_str(&response.body).map_err(|error| { + ToolError::ExecutionFailed(format!( + "Backend returned invalid assignment tracking JSON: {error}" + )) + })?; + Ok(ToolOutput { + summary: self.kind.name().into(), + content: Some(content.to_string()), + attachments: Vec::new(), + }) + } +} + +pub(super) fn definition( + client: Arc, + kind: TrackingToolKind, +) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new(kind.name()) + .description(description(kind.name())) + .input_schema(kind.schema()), + Arc::new(TrackingTool { + client: client.clone(), + kind, + }) as Arc, + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worker::{WorkspaceClientError, WorkspaceResponse}; + + #[derive(Debug)] + struct Transport { + workspace: Option, + response: WorkspaceResponse, + requests: Mutex>, + } + impl WorkspaceClient for Transport { + fn workspace_id(&self) -> Option<&str> { + self.workspace.as_deref() + } + fn kind(&self) -> &str { + "tracking-contract-test" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(self.response.clone()) + } + } + fn transport(status: u16, body: &str) -> Arc { + Arc::new(Transport { + workspace: Some("workspace".into()), + response: WorkspaceResponse { + status, + body: body.into(), + }, + requests: Mutex::new(Vec::new()), + }) + } + fn update() -> Value { + json!({"ticket":"T-741", "assignment_id":"delegation", "expected_last_event_id":"assigned-event", "operation_key":"report", "tracking":{"status":"done", "waiting_reason":null, "progress":"Alternatives compared", "next_action":"Human decision", "result":"Returned options"}}) + } + + #[tokio::test] + async fn assignment_tracking_update_requires_observed_identity_and_excludes_actor_claims() { + let client = transport( + 200, + r#"{"assignment_id":"delegation","last_event_id":"reported-event","tracking":{"status":"done"}}"#, + ); + let (meta, tool) = definition(client.clone(), TrackingToolKind::Update)(); + assert_eq!(meta.name, "TicketAssignmentTrackingUpdate"); + let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); + assert!(validator.is_valid(&update())); + for field in [ + "assignment_id", + "expected_last_event_id", + "operation_key", + "tracking", + ] { + let mut input = update(); + input.as_object_mut().unwrap().remove(field); + assert!(!validator.is_valid(&input), "required {field}"); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .is_err() + ); + } + for field in [ + "author", + "role", + "runtime_id", + "worker_id", + "source_context", + ] { + let mut input = update(); + input[field] = json!("orchestrator"); + assert!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .is_err(), + "reject {field}" + ); + } + assert!(client.requests.lock().unwrap().is_empty()); + for _ in 0..2 { + let output = tool + .execute(&update().to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + assert_eq!( + serde_json::from_str::(&output.content.unwrap()).unwrap()["last_event_id"], + "reported-event" + ); + } + let requests = client.requests.lock().unwrap(); + assert_eq!( + requests[0], requests[1], + "uncertain retry preserves full input" + ); + assert_eq!( + requests[0].path, + "/workspaces/workspace/tickets/T-741/assignment-tracking" + ); + let body: Value = serde_json::from_str(requests[0].body.as_ref().unwrap()).unwrap(); + assert_eq!(body["tracking"]["status"], "done"); + assert!(body.get("ticket").is_none()); + assert!(body.get("author").is_none()); + } + + #[tokio::test] + async fn assignment_tracking_reads_are_bounded_and_carry_history_cursor() { + let client = transport(200, r#"{"items":[],"next_event_id":null}"#); + let (_, tool) = definition(client.clone(), TrackingToolKind::History)(); + for limit in [0, 101] { + assert!( + tool.execute( + &json!({"ticket":"T-741", "assignment_id":"a", "limit":limit}).to_string(), + ToolExecutionContext::direct() + ) + .await + .is_err() + ); + } + tool.execute( + &json!({"ticket":"T-741", "assignment_id":"a", "after_event_id":"event/1", "limit":10}) + .to_string(), + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); + assert!( + requests[0] + .path + .ends_with("?assignment_id=a&limit=10&after_event_id=event%2F1") + ); + drop(requests); + let oversized = transport(200, &"x".repeat(1024 * 1024 + 1)); + let (_, tool) = definition(oversized, TrackingToolKind::Query)(); + assert!( + tool.execute(r#"{"ticket":"T-741"}"#, ToolExecutionContext::direct()) + .await + .unwrap_err() + .to_string() + .contains("1 MiB") + ); + } + + #[tokio::test] + async fn retained_assignment_query_encodes_identity_without_changing_workspace_scope() { + let client = transport( + 200, + r#"[{"assignment_id":"old/delegation?x","current":false,"release":{"action":"replaced"}}]"#, + ); + let (_, tool) = definition(client.clone(), TrackingToolKind::Query)(); + let output = tool + .execute( + r#"{"ticket":"T-741","assignment_id":"old/delegation?x"}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + let content: Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(content[0]["current"], false); + assert_eq!(content[0]["release"]["action"], "replaced"); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); + assert_eq!( + requests[0].path, + "/workspaces/workspace/tickets/T-741/assignment-tracking?assignment_id=old%2Fdelegation%3Fx" + ); + } + + #[tokio::test] + async fn assignment_tracking_requires_backend_and_propagates_authority_rejection() { + let client = Arc::new(Transport { + workspace: None, + response: WorkspaceResponse { + status: 200, + body: "{}".into(), + }, + requests: Mutex::new(Vec::new()), + }); + let (_, tool) = definition(client.clone(), TrackingToolKind::Update)(); + assert!( + tool.execute(&update().to_string(), ToolExecutionContext::direct()) + .await + .unwrap_err() + .to_string() + .contains("Backend Workspace identity") + ); + assert!(client.requests.lock().unwrap().is_empty()); + let client = transport(403, r#"{"code":"current_orchestrator_required"}"#); + let (_, tool) = definition(client, TrackingToolKind::Update)(); + assert!( + tool.execute(&update().to_string(), ToolExecutionContext::direct()) + .await + .unwrap_err() + .to_string() + .contains("403") + ); + } + + #[test] + fn assignment_tracking_tools_share_native_wip_and_access_contract() { + let client = transport(200, "{}"); + let definitions = enabled_ticket_definitions(client, TicketFeatureAccess::workflow()); + for name in TOOL_NAMES { + let (meta, _) = definitions + .iter() + .find(|definition| definition().0.name == *name) + .unwrap()(); + let native = native_ticket_operation(name).unwrap(); + assert_eq!(native.identity_field, Some("ticket")); + assert_eq!(native.mutating, *name == "TicketAssignmentTrackingUpdate"); + assert!( + meta.description.contains("delegation") + || meta.description.contains("assignment_id") + ); + } + let reads = TicketFeatureAccess::read_only().tool_names(); + assert!(reads.contains(&"TicketAssignmentTrackingQuery")); + assert!(reads.contains(&"TicketAssignmentTrackingHistory")); + assert!(!reads.contains(&"TicketAssignmentTrackingUpdate")); + } +} diff --git a/crates/worker/src/feature/builtin/ticket/tags.rs b/crates/worker/src/feature/builtin/ticket/tags.rs new file mode 100644 index 000000000..46f33cfbb --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/tags.rs @@ -0,0 +1,284 @@ +//! Classification tools share the Ticket feature's scoped Backend transport. +#[cfg(test)] +mod tests; +use super::*; + +pub(super) const TAG_TOOL_NAMES: &[&str] = &[ + "WorkspaceTagList", + "WorkspaceTagCreate", + "WorkspaceTagRename", + "WorkspaceTagDelete", + "TicketTagsSet", +]; + +#[derive(Clone, Copy)] +pub(super) enum TagToolKind { + List, + Create, + Rename, + Delete, + Set, +} + +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct ListInput { + /// Continue strictly after this stable tag ID. Continue until an empty page. + after: Option, + /// Maximum results, 0..=200; defaults to 200. + #[schemars(range(max = 200))] + limit: Option, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct CreateInput { + /// Display name: nonempty after Unicode trimming, no controls, at most 128 UTF-8 bytes. + name: String, + /// Retry an uncertain request with this same key and exact input. + operation_key: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct RenameInput { + tag_id: String, + /// Last operation key observed on this exact identity; rename-back is still a change. + expected_last_operation_key: String, + name: String, + operation_key: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct DeleteInput { + tag_id: String, + expected_last_operation_key: String, + operation_key: String, +} +#[derive(Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct SetInput { + /// Ticket reference, preferably T-*; WIP binds it from the item route. + ticket: String, + /// Entire active ID set observed in ShowTicket/QueryTicket; never silently refreshed. + #[schemars(length(max = 100))] + expected_tag_ids: Vec, + /// Desired entire ID set. Attach/remove multiple tags atomically; [] removes all. + #[schemars(length(max = 100))] + tag_ids: Vec, + operation_key: String, +} + +impl TagToolKind { + pub(super) fn name(self) -> &'static str { + match self { + Self::List => "WorkspaceTagList", + Self::Create => "WorkspaceTagCreate", + Self::Rename => "WorkspaceTagRename", + Self::Delete => "WorkspaceTagDelete", + Self::Set => "TicketTagsSet", + } + } + fn schema(self) -> Value { + match self { + Self::List => json!(schemars::schema_for!(ListInput)), + Self::Create => json!(schemars::schema_for!(CreateInput)), + Self::Rename => json!(schemars::schema_for!(RenameInput)), + Self::Delete => json!(schemars::schema_for!(DeleteInput)), + Self::Set => json!(schemars::schema_for!(SetInput)), + } + } +} + +pub(super) fn description(name: &str) -> &'static str { + match name { + "WorkspaceTagList" => { + "List active Workspace classification tags in stable ID order, bounded to 200. Continue after the last ID until exhausted; a full page does not promise another page." + } + "WorkspaceTagCreate" => { + "Create a Workspace classification tag with a stable identity and replay key. Names are ordinary classification, never Ticket states, routing, or authority grants." + } + "WorkspaceTagRename" => { + "Rename a Workspace classification tag by stable ID and observed last_operation_key. Links retain identity; history is not rewritten. Retry uncertain requests with the same key/input, then reread before further edits." + } + "WorkspaceTagDelete" => { + "Tombstone a Workspace classification tag using its observed last_operation_key and atomically remove current Ticket membership. Prior history remains; recreating the name creates a new identity." + } + "TicketTagsSet" => { + "Atomically compare and replace a Ticket's entire active classification tag ID set. Compute desired tag_ids from observed expected_tag_ids to attach/remove multiple tags without overwriting unrelated changes. At most 100 unique IDs. Classification does not edit state, content digest, assignment, or grants. A replay is the original receipt, not a fresh observation; reread before further edits." + } + _ => unreachable!("unknown classification tool"), + } +} + +fn required(name: &str, value: &str) -> Result<(), ToolError> { + if value.trim().is_empty() { + return Err(ToolError::InvalidArgument(format!( + "{name} must not be empty" + ))); + } + Ok(()) +} +fn validate_name(name: &str) -> Result<(), ToolError> { + if name.trim().is_empty() || name.trim().len() > 128 || name.chars().any(char::is_control) { + return Err(ToolError::InvalidArgument("name must be nonempty, contain no control characters, and be at most 128 UTF-8 bytes after trimming".into())); + } + Ok(()) +} + +pub(super) fn validate_ids(name: &str, ids: &[String]) -> Result<(), ToolError> { + if ids.len() > 100 { + return Err(ToolError::InvalidArgument(format!( + "{name} must contain at most 100 IDs" + ))); + } + let mut unique = BTreeSet::new(); + for id in ids { + required(name, id)?; + if !unique.insert(id) { + return Err(ToolError::InvalidArgument(format!( + "{name} must contain unique IDs" + ))); + } + } + Ok(()) +} + +fn parse(input: &str) -> Result { + serde_json::from_str(input).map_err(|error| ToolError::InvalidArgument(error.to_string())) +} + +#[derive(Clone)] +struct TagTool { + client: Arc, + kind: TagToolKind, +} + +#[async_trait] +impl Tool for TagTool { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { + self.client + .workspace_id() + .filter(|id| !id.trim().is_empty()) + .ok_or_else(|| { + ToolError::InvalidArgument( + "Classification tools require Backend Workspace identity; no local fallback" + .into(), + ) + })?; + let backend = WorkspaceHttpTicketBackend::new(self.client.clone()); + let content = match self.kind { + TagToolKind::List => { + let input: ListInput = parse(input)?; + if input.limit.is_some_and(|limit| limit > 200) { + return Err(ToolError::InvalidArgument( + "limit must be at most 200".into(), + )); + } + if let Some(after) = &input.after { + required("after", after)?; + } + let limit = input.limit.unwrap_or(200); + let mut tags = backend + .list_workspace_tags(ticket::TagListRequest { + after: input.after, + limit: Some(limit), + }) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + tags.truncate(limit); + json!(tags) + } + TagToolKind::Set => { + let input: SetInput = parse(input)?; + required("ticket", &input.ticket)?; + required("operation_key", &input.operation_key)?; + validate_ids("expected_tag_ids", &input.expected_tag_ids)?; + validate_ids("tag_ids", &input.tag_ids)?; + let tags = backend + .set_ticket_tags( + TicketIdOrSlug::from(input.ticket.as_str()), + ticket::TicketTagSetRequest { + expected_tag_ids: input.expected_tag_ids, + tag_ids: input.tag_ids, + operation_key: input.operation_key, + }, + ) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + if tags.len() > 100 { + return Err(ToolError::ExecutionFailed( + "Backend returned more than 100 Ticket tags".into(), + )); + } + json!(tags) + } + kind => { + let request = match kind { + TagToolKind::Create => { + let input: CreateInput = parse(input)?; + validate_name(&input.name)?; + ticket::WorkspaceTagMutation { + operation_key: input.operation_key, + action: ticket::WorkspaceTagAction::Create { name: input.name }, + } + } + TagToolKind::Rename => { + let input: RenameInput = parse(input)?; + required("tag_id", &input.tag_id)?; + required( + "expected_last_operation_key", + &input.expected_last_operation_key, + )?; + validate_name(&input.name)?; + ticket::WorkspaceTagMutation { + operation_key: input.operation_key, + action: ticket::WorkspaceTagAction::Rename { + tag_id: input.tag_id, + expected_last_operation_key: input.expected_last_operation_key, + name: input.name, + }, + } + } + TagToolKind::Delete => { + let input: DeleteInput = parse(input)?; + required("tag_id", &input.tag_id)?; + required( + "expected_last_operation_key", + &input.expected_last_operation_key, + )?; + ticket::WorkspaceTagMutation { + operation_key: input.operation_key, + action: ticket::WorkspaceTagAction::Delete { + tag_id: input.tag_id, + expected_last_operation_key: input.expected_last_operation_key, + }, + } + } + _ => unreachable!(), + }; + required("operation_key", &request.operation_key)?; + json!( + backend + .mutate_workspace_tag(request) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))? + ) + } + }; + Ok(ToolOutput { + summary: self.kind.name().into(), + content: Some(content.to_string()), + attachments: Vec::new(), + }) + } +} + +pub(super) fn definition(client: Arc, kind: TagToolKind) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new(kind.name()) + .description(description(kind.name())) + .input_schema(kind.schema()), + Arc::new(TagTool { + client: client.clone(), + kind, + }) as Arc, + ) + }) +} diff --git a/crates/worker/src/feature/builtin/ticket/tags/tests.rs b/crates/worker/src/feature/builtin/ticket/tags/tests.rs new file mode 100644 index 000000000..b1044b0e0 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/tags/tests.rs @@ -0,0 +1,561 @@ +use super::*; +use crate::worker::{WorkspaceClientError, WorkspaceResponse}; + +#[derive(Debug)] +struct RecordingClient { + requests: Mutex>, + body: String, + identity: bool, + status: u16, +} +impl WorkspaceClient for RecordingClient { + fn workspace_id(&self) -> Option<&str> { + self.identity.then_some("workspace") + } + fn kind(&self) -> &str { + "classification-test" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + self.requests.lock().unwrap().push(request); + Ok(WorkspaceResponse { + status: self.status, + body: self.body.clone(), + }) + } +} +fn recording(body: Value) -> Arc { + Arc::new(RecordingClient { + requests: Mutex::new(Vec::new()), + body: body.to_string(), + identity: true, + status: 200, + }) +} +fn tag() -> Value { + json!({"workspace_id":"workspace", "id":"tag-1", "name":"Ready", "last_operation_key":"create-1", "deleted":false}) +} +fn cases() -> Vec<(TagToolKind, Value, &'static str)> { + vec![ + (TagToolKind::List, json!({"limit":5}), "WorkspaceTagList"), + ( + TagToolKind::Create, + json!({"name":"Ready", "operation_key":"create-1"}), + "WorkspaceTagCreate", + ), + ( + TagToolKind::Rename, + json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "name":"New", "operation_key":"rename-1"}), + "WorkspaceTagRename", + ), + ( + TagToolKind::Delete, + json!({"tag_id":"tag-1", "expected_last_operation_key":"create-1", "operation_key":"delete-1"}), + "WorkspaceTagDelete", + ), + ( + TagToolKind::Set, + json!({"ticket":"T-740", "expected_tag_ids":[], "tag_ids":["tag-1", "tag-2"], "operation_key":"set-1"}), + "TicketTagsSet", + ), + ] +} + +#[tokio::test] +async fn classification_tool_schemas_require_observations_and_reject_actor_claims() { + for (kind, input, name) in cases() { + let client = recording(json!([])); + let (meta, tool) = definition(client.clone(), kind)(); + assert_eq!(meta.name, name); + let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); + assert!(validator.is_valid(&input), "{name}: {}", meta.input_schema); + for field in meta.input_schema["required"] + .as_array() + .into_iter() + .flatten() + { + let field = field.as_str().unwrap(); + let mut invalid = input.clone(); + invalid.as_object_mut().unwrap().remove(field); + assert!(!validator.is_valid(&invalid), "{name}.{field}"); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + let mut invalid = input.clone(); + invalid["author"] = json!("orchestrator"); + assert!(!validator.is_valid(&invalid)); + assert!(matches!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + assert!(client.requests.lock().unwrap().is_empty()); + } + let schema = TagToolKind::Set.schema(); + assert_eq!(schema["properties"]["tag_ids"]["maxItems"], 100); + assert_eq!(schema["properties"]["expected_tag_ids"]["maxItems"], 100); + assert_eq!( + TagToolKind::List.schema()["properties"]["limit"]["maximum"], + 200 + ); +} + +#[tokio::test] +async fn classification_reads_forward_id_filters_and_preserve_tag_observations() { + let item = json!({"resource_key":"T-740", "title":"Classify", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]}); + let client = recording(json!({"items":[item], "page":{"has_more":false, "next_cursor":null}})); + let (meta, tool) = + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)(); + for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] { + assert_eq!(meta.input_schema["properties"][field]["maxItems"], 100); + } + let input = json!({"tag_ids":["tag-1"], "tag_ids_any":["tag-2", "tag-3"], "tag_ids_none":["tag-4"], "states":["open"], "limit":7}); + let output = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(value["tickets"][0]["tags"], json!([tag()])); + assert_eq!(value["tickets"][0]["state"], "open"); + for field in ["closure_operation_key", "current_closure", "legacy_handoff"] { + assert!( + value["tickets"][0].as_object().unwrap().contains_key(field), + "{field}" + ); + assert_eq!(value["tickets"][0][field], Value::Null); + } + { + let requests = client.requests.lock().unwrap(); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + for field in ["states", "tag_ids", "tag_ids_any", "tag_ids_none"] { + assert_eq!(body[field], input[field]); + } + } + let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]}); + let client = recording(detail); + let (_, tool) = + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)(); + let output = tool + .execute(r#"{"id":"T-740"}"#, ToolExecutionContext::direct()) + .await + .unwrap(); + let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(value["tags"], json!([tag()])); + assert_eq!(value["content_digest"], "content-1"); + assert_eq!(value["state"], "open"); + assert_eq!(value["closure_history"], json!([])); + for field in ["closure_operation_key", "current_closure", "legacy_handoff"] { + assert!(value.as_object().unwrap().contains_key(field), "{field}"); + assert_eq!(value[field], Value::Null); + } + let client = recording(json!({})); + let (_, tool) = + workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Query)(); + for field in ["tag_ids", "tag_ids_any", "tag_ids_none"] { + let input = json!({field: (0..101).map(|i| format!("tag-{i}")).collect::>()}); + assert!(matches!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "invalid", + ] { + let input = json!({"states":[state], "tag_ids":["tag-1"]}); + assert!( + matches!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + ), + "{state}" + ); + } + assert!(client.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn classification_tools_reject_invalid_names_ids_and_keys_before_dispatch() { + let client = recording(json!([])); + for name in [" ".to_string(), "x\n".to_string(), "é".repeat(65)] { + let (_, tool) = definition(client.clone(), TagToolKind::Create)(); + assert!(matches!( + tool.execute( + &json!({"name":name, "operation_key":"create"}).to_string(), + ToolExecutionContext::direct() + ) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + for ids in [ + json!(["tag-1", "tag-1"]), + json!([""]), + json!((0..101).map(|i| format!("tag-{i}")).collect::>()), + ] { + let (_, tool) = definition(client.clone(), TagToolKind::Set)(); + for field in ["tag_ids", "expected_tag_ids"] { + let mut input = json!({"ticket":"T-740", "tag_ids":[], "expected_tag_ids":[], "operation_key":"set"}); + input[field] = ids.clone(); + assert!(matches!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + } + for (kind, mut input, _) in cases() + .into_iter() + .filter(|(kind, _, _)| !matches!(kind, TagToolKind::List)) + { + input["operation_key"] = json!(" "); + let (_, tool) = definition(client.clone(), kind)(); + assert!(matches!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + let (_, tool) = definition(client.clone(), TagToolKind::List)(); + assert!( + tool.execute(r#"{"limit":201}"#, ToolExecutionContext::direct()) + .await + .is_err() + ); + assert!(client.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn classification_tools_require_backend_workspace_and_bound_list_results() { + let missing = Arc::new(RecordingClient { + requests: Mutex::new(Vec::new()), + body: "[]".into(), + identity: false, + status: 200, + }); + for (kind, input, _) in cases() { + let (_, tool) = definition(missing.clone(), kind)(); + assert!(matches!( + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await, + Err(ToolError::InvalidArgument(_)) + )); + } + assert!(missing.requests.lock().unwrap().is_empty()); + let client = recording(json!(vec![tag(); 201])); + let (_, tool) = definition(client.clone(), TagToolKind::List)(); + for (input, expected) in [ + (json!({}), 200), + (json!({"limit":2, "after":"a&b"}), 2), + (json!({"limit":0}), 0), + ] { + let output = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let value: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(value.as_array().unwrap().len(), expected); + } + let requests = client.requests.lock().unwrap(); + assert_eq!( + requests[1].path, + "/api/w/workspace/ticket-tags?limit=2&after=a%26b" + ); +} + +#[tokio::test] +async fn classification_wip_uses_normal_permissions_and_route_bound_ticket_identity() { + let client = recording(json!([tag()])); + let tools = + native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring()).unwrap(); + let set = tools + .iter() + .find(|tool| tool.name == "TicketTagsSet") + .unwrap(); + let (_, input, _) = cases().pop().unwrap(); + let permissions = Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Allow, + rules: vec![manifest::ToolPermissionRule { + tool: "TicketTagsSet".into(), + action: ToolPermissionAction::Deny, + pattern: input.to_string(), + }], + }); + let error = execute_native_ticket_tool( + set, + &permissions, + input, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into(), + }, + ) + .await + .unwrap_err(); + assert!(matches!( + error, + WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + }) + )); + assert!(client.requests.lock().unwrap().is_empty()); + let handler = TicketItemWipHandler { + tools: operation_map( + tools + .into_iter() + .filter(|tool| tool.projection.surface == NativeTicketSurface::Item) + .collect(), + ), + permissions: None, + ticket_reference: "T-740".into(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), + }; + handler + .observations + .lock() + .unwrap() + .content_digests + .insert("T-740".into(), "content-1".into()); + let mut args = BTreeMap::from([ + ("operation_key".into(), WipValue::String("set-1".into())), + ("tag_ids".into(), json_to_wip(&json!(["tag-1"])).unwrap()), + ("expected_tag_ids".into(), json_to_wip(&json!([])).unwrap()), + ]); + assert!( + handler + .call( + "tags_set", + &args, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into(), + }, + ) + .await + .is_ok() + ); + assert_eq!( + client.requests.lock().unwrap()[0].path, + "/api/w/workspace/tickets/T-740/tags" + ); + assert_eq!( + handler + .observations + .lock() + .unwrap() + .content_digests + .get("T-740") + .map(String::as_str), + Some("content-1") + ); + args.insert("ticket".into(), WipValue::String("T-foreign".into())); + assert!( + handler + .call( + "tags_set", + &args, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "worker".into() + } + ) + .await + .is_err() + ); + assert_eq!(client.requests.lock().unwrap().len(), 1); + let read_only = TicketFeatureAccess::read_only().tool_names(); + assert!(read_only.contains(&"WorkspaceTagList")); + assert!(!read_only.contains(&"TicketTagsSet")); +} + +#[tokio::test] +async fn classification_tools_preserve_backend_permission_and_stale_set_failures() { + for status in [403, 409] { + let client = Arc::new(RecordingClient { + requests: Mutex::new(Vec::new()), + body: "{}".into(), + identity: true, + status, + }); + let (_, tool) = definition(client.clone(), TagToolKind::Set)(); + let (_, input, _) = cases().pop().unwrap(); + let error = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap_err(); + assert!(error.to_string().contains(&status.to_string())); + assert_eq!(client.requests.lock().unwrap().len(), 1); + } +} + +#[test] +fn classification_tools_http_mutations_survive_backend_reopen_and_stale_compare() { + use std::io::{Read, Write}; + let dir = tempfile::tempdir().unwrap(); + let db = dir.path().join("tags.db"); + let backend = ticket::SqliteTicketBackend::open(&db, "workspace").unwrap(); + let reference = backend.create(NewTicket::new("Classify")).unwrap(); + let initial_content_digest = + ticket::ticket_content_digest(&backend.show(reference.id.clone().into()).unwrap()); + let ticket_id = reference.id.clone(); + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let server_db = db.clone(); + let server = std::thread::spawn(move || { + for _ in 0..10 { + let (mut stream, _) = listener.accept().unwrap(); + stream + .set_read_timeout(Some(std::time::Duration::from_secs(10))) + .unwrap(); + let mut bytes = Vec::new(); + let (headers, body) = loop { + let mut chunk = [0u8; 8192]; + let count = stream.read(&mut chunk).unwrap(); + assert!(count > 0); + bytes.extend_from_slice(&chunk[..count]); + if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + let headers = String::from_utf8(bytes[..end].to_vec()).unwrap(); + let length = headers + .lines() + .find_map(|line| { + line.to_lowercase() + .strip_prefix("content-length:") + .and_then(|v| v.trim().parse::().ok()) + }) + .unwrap_or(0); + if bytes.len() >= end + 4 + length { + break (headers, bytes[end + 4..end + 4 + length].to_vec()); + } + } + }; + let backend = + ticket::SqliteTicketBackend::open_verified(&server_db, "workspace").unwrap(); + let line = headers.lines().next().unwrap(); + let result = if line.starts_with("POST /api/w/workspace/ticket-tags ") { + backend + .mutate_workspace_tag(serde_json::from_slice(&body).unwrap()) + .map(|tag| json!(tag)) + } else if line.starts_with(&format!("PUT /api/w/workspace/tickets/{ticket_id}/tags ")) { + backend + .set_ticket_tags( + ticket_id.clone().into(), + serde_json::from_slice(&body).unwrap(), + ) + .map(|tags| json!(tags)) + } else { + assert!( + line.starts_with("GET /api/w/workspace/ticket-tags?"), + "{line}" + ); + backend + .list_workspace_tags(ticket::TagListRequest::default()) + .map(|tags| json!(tags)) + }; + let (status, response) = match result { + Ok(value) => ("200 OK", value.to_string()), + Err(error) => ( + "409 Conflict", + json!({"message":error.to_string()}).to_string(), + ), + }; + write!(stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{response}", response.len()).unwrap(); + } + }); + let client: Arc = Arc::new(crate::worker::TestWorkspaceHttpClient::new( + "workspace", + base, + )); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .unwrap(); + let call = |kind, input: Value| { + runtime.block_on(async { + let (_, tool) = definition(client.clone(), kind)(); + tool.execute(&input.to_string(), ToolExecutionContext::direct()) + .await + }) + }; + let first: ticket::WorkspaceTag = serde_json::from_str( + call( + TagToolKind::Create, + json!({"name":"ready", "operation_key":"create-a"}), + ) + .unwrap() + .content + .as_deref() + .unwrap(), + ) + .unwrap(); + let second: ticket::WorkspaceTag = serde_json::from_str( + call( + TagToolKind::Create, + json!({"name":"closed", "operation_key":"create-b"}), + ) + .unwrap() + .content + .as_deref() + .unwrap(), + ) + .unwrap(); + let set = json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"set-both"}); + let receipt = call(TagToolKind::Set, set.clone()).unwrap(); + assert_eq!( + call(TagToolKind::Set, set).unwrap().content, + receipt.content + ); + assert!(call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[], "operation_key":"stale"})).is_err()); + let removed = call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[second.id, first.id], "tag_ids":[], "operation_key":"remove-both"})).unwrap(); + assert_eq!(removed.content.as_deref(), Some("[]")); + assert!( + backend + .show(reference.id.clone().into()) + .unwrap() + .tags + .is_empty() + ); + call(TagToolKind::Set, json!({"ticket":reference.id, "expected_tag_ids":[], "tag_ids":[first.id, second.id], "operation_key":"reattach-both"})).unwrap(); + call(TagToolKind::Rename, json!({"tag_id":first.id, "expected_last_operation_key":first.last_operation_key, "name":"renamed", "operation_key":"rename-a"})).unwrap(); + call(TagToolKind::Delete, json!({"tag_id":second.id, "expected_last_operation_key":second.last_operation_key, "operation_key":"delete-b"})).unwrap(); + let listed = call(TagToolKind::List, json!({})).unwrap(); + let tags: Vec = + serde_json::from_str(listed.content.as_deref().unwrap()).unwrap(); + assert_eq!(tags.len(), 1); + assert_eq!(tags[0].name, "renamed"); + server.join().unwrap(); + let reopened = ticket::SqliteTicketBackend::open_verified(&db, "workspace").unwrap(); + let ticket = reopened.show(reference.id.into()).unwrap(); + assert_eq!(ticket.tags, tags); + assert_eq!( + ticket::ticket_content_digest(&ticket), + initial_content_digest + ); + assert_eq!(ticket.meta.workflow_state, TicketState::Open); + assert!(ticket.current_closure.is_none()); + assert!(ticket.closure_operation_key.is_none()); + assert!(ticket.case_history.is_empty()); + assert_eq!( + ticket + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .count(), + 4, + "replay and stale input must not append membership events" + ); +} diff --git a/crates/worker/src/feature/mcp.rs b/crates/worker/src/feature/mcp.rs index 5fbeb4cf5..259af6db3 100644 --- a/crates/worker/src/feature/mcp.rs +++ b/crates/worker/src/feature/mcp.rs @@ -1090,12 +1090,22 @@ impl Tool for McpStdioTool { &self, input_json: &str, _ctx: ToolExecutionContext, + ) -> Result { + self.execute_with_limits(input_json, McpStdioLimits::default()) + .await + } +} + +impl McpStdioTool { + async fn execute_with_limits( + &self, + input_json: &str, + limits: McpStdioLimits, ) -> Result { let arguments = parse_tool_arguments(input_json)?; - let mut client = - McpStdioClient::connect(self.server_spec.clone(), McpStdioLimits::default()) - .await - .map_err(|err| ToolError::ExecutionFailed(mcp_call_error_message(&err)))?; + let mut client = McpStdioClient::connect(self.server_spec.clone(), limits) + .await + .map_err(|err| ToolError::ExecutionFailed(mcp_call_error_message(&err)))?; let call_result = client .call_tool(CallToolRequest::new(self.mcp_tool_name.clone(), arguments)) @@ -2217,6 +2227,64 @@ read exit_notification || true ); } + #[cfg(unix)] + #[tokio::test] + async fn stdio_tool_request_timeout_reports_unknown_execution_without_replay() { + let dir = tempfile::tempdir().unwrap(); + let log = dir.path().join("frames.jsonl"); + // Frame capture is observed only after shutdown has waited for process + // exit. No guessed sleep or process-global test-control variable. + let script = r#" +printf '%s\n' 'connection' >> "$1" +read -r init || exit 1 +printf '%s\n' '{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":"2025-11-25","capabilities":{"tools":{}},"serverInfo":{"name":"timeout-fixture","version":"1"}}}' +read -r initialized || exit 1 +while IFS= read -r frame; do + printf '%s\n' "$frame" >> "$1" +done +"#; + let tool = McpStdioTool { + server_spec: McpStdioServerSpec::new("timeout-fixture", "/bin/sh").args([ + "-c".to_string(), + script.to_string(), + "fixture".to_string(), + log.to_str().unwrap().to_string(), + ]), + mcp_tool_name: "demo-tool".to_string(), + }; + let limits = McpStdioLimits { + request_timeout: std::time::Duration::from_millis(100), + shutdown_timeout: std::time::Duration::from_millis(100), + kill_timeout: std::time::Duration::from_millis(100), + ..Default::default() + }; + let err = tokio::time::timeout( + std::time::Duration::from_secs(3), + tool.execute_with_limits("{}", limits), + ) + .await + .unwrap() + .expect_err("response intentionally withheld"); + let message = err.to_string(); + assert!( + message.contains("tools/call transport/protocol failure"), + "{message}" + ); + assert!(message.contains("timed out during tools/call"), "{message}"); + assert!( + message.contains("frame flushed; execution unknown; do not retry"), + "{message}" + ); + let capture = std::fs::read_to_string(log).unwrap(); + let mut lines = capture.lines(); + assert_eq!(lines.next(), Some("connection")); + let frames: Vec = lines + .map(|line| serde_json::from_str(line).unwrap()) + .collect(); + assert_eq!(frames.len(), 1, "must not reconnect or replay: {capture}"); + assert_eq!(frames[0]["method"], "tools/call"); + } + #[test] fn call_tool_result_renderer_marks_untrusted_mcp_error() { let result = CallToolResult { diff --git a/crates/worker/src/internal_worker.rs b/crates/worker/src/internal_worker.rs index 13973591c..7ffc44b42 100644 --- a/crates/worker/src/internal_worker.rs +++ b/crates/worker/src/internal_worker.rs @@ -68,6 +68,7 @@ pub(crate) struct InternalWorkerSpec { ) + Send, >, >, + pub compaction_budget: Option, pub features: FeatureRegistryBuilder, pub required_tools: &'static [&'static str], pub authority: InternalWorkerAuthority, @@ -118,6 +119,7 @@ where cache_key, max_turns, engine_configurator, + compaction_budget, features, required_tools, authority, @@ -171,6 +173,7 @@ where identity: identity.clone(), history_entries: 0, })?; + worker.install_compaction_budget(compaction_budget); worker.disable_manifest_lifecycle_features(); if let Some(session) = inherited_workdir_session { worker.bind_single_workdir_session(Some(session)); @@ -666,6 +669,7 @@ pub(crate) async fn spawn_internal_worker_session( cache_key, max_turns, engine_configurator, + compaction_budget, features, required_tools, authority, @@ -701,6 +705,7 @@ pub(crate) async fn spawn_internal_worker_session( .map_err(|source| InternalWorkerSessionError::Build { message: source.to_string(), })?; + worker.install_compaction_budget(compaction_budget); worker.disable_manifest_lifecycle_features(); if let Some(session) = inherited_workdir_session { worker.bind_single_workdir_session(Some(session)); @@ -762,6 +767,7 @@ pub(crate) fn prepare_internal_worker_from_spec( cache_key, max_turns, engine_configurator, + compaction_budget, features, required_tools, authority, @@ -794,6 +800,7 @@ pub(crate) fn prepare_internal_worker_from_spec( .map_err(|source| InternalWorkerSessionError::Build { message: source.to_string(), })?; + worker.install_compaction_budget(compaction_budget); worker.disable_manifest_lifecycle_features(); if let Some(session) = inherited_workdir_session { worker.bind_single_workdir_session(Some(session)); @@ -1442,6 +1449,7 @@ permission = "write" input: "input".to_string(), cache_key: Some("internal-test".to_string()), max_turns: Some(1), + compaction_budget: None, engine_configurator: None, features: FeatureRegistryBuilder::new(), required_tools, diff --git a/crates/worker/src/ipc/interceptor.rs b/crates/worker/src/ipc/interceptor.rs index bfd713a4c..9a23e5cfe 100644 --- a/crates/worker/src/ipc/interceptor.rs +++ b/crates/worker/src/ipc/interceptor.rs @@ -74,6 +74,7 @@ async fn call_hook_before_deadline( } pub(crate) struct WorkerInterceptor { + compaction_budget: Option>, registry: Arc, compact_state: Option>, /// Shared view of the cumulative UsageRecord timeline. Used with the @@ -157,6 +158,7 @@ impl WorkerInterceptor { pending_committed_history: Arc>>>, ) -> Self { Self { + compaction_budget: None, registry, compact_state, usage_history, @@ -175,6 +177,14 @@ impl WorkerInterceptor { } } + pub(crate) fn with_compaction_budget( + mut self, + budget: Option>, + ) -> Self { + self.compaction_budget = budget; + self + } + pub(crate) fn with_notification_committer( mut self, committer: Option>, @@ -552,6 +562,8 @@ impl Interceptor for WorkerInterceptor { &self, context: PreLlmRequestContext<'_, SessionHistoryMetadata>, ) -> InterceptorResult { + let context_invocation = context.invocation; + let context_history = context.history; let context = context.items; let initial_tokens = self.estimated_tokens(context); if let Some(action) = @@ -616,7 +628,7 @@ impl Interceptor for WorkerInterceptor { .expect("pending hook system-item queue poisoned"), ); self.attach_prompt_provenance(&mut system_items); - let appended_items: Vec = system_items + let mut appended_items: Vec = system_items .iter() .map(SystemItem::to_history_item) .collect(); @@ -652,10 +664,28 @@ impl Interceptor for WorkerInterceptor { }); } - if let Some(usage_tracker) = self.usage_tracker.as_ref() { - usage_tracker.note_request(effective_context.len()); + // Internal compaction safety must survive the normal Worker interceptor + // installation. Run it on the complete request after durable hook additions; + // Engine persists any returned warning through its normal append path. + if let Some(budget) = self.compaction_budget.as_ref() { + let mut budget_context = effective_context.into_owned(); + match budget + .pre_llm_request(PreLlmRequestContext { + invocation: context_invocation, + items: &mut budget_context, + history: context_history, + }) + .await? + { + PreRequestAction::Continue => {} + PreRequestAction::ContinueWith(warnings) => appended_items.extend(warnings), + action => return Ok(action), + } } - if system_items.is_empty() { + if let Some(usage_tracker) = self.usage_tracker.as_ref() { + usage_tracker.note_request(context.len() + appended_items.len()); + } + if appended_items.is_empty() { return Ok(PreRequestAction::Continue); } Ok(match self.commit_system_items(&system_items) { @@ -700,6 +730,12 @@ impl Interceptor for WorkerInterceptor { if let Some(reason) = denials.into_iter().next() { return Ok(HookPreToolAction::Deny(reason).into_worker_action(summary.call_id.clone())); } + if let Some(budget) = self.compaction_budget.as_ref() { + match budget.pre_tool_call(info).await? { + PreToolAction::Continue => {} + action => return Ok(action), + } + } self.tool_calls_this_turn.fetch_add(1, Ordering::Relaxed); Ok(PreToolAction::Continue) } diff --git a/crates/worker/src/job.rs b/crates/worker/src/job.rs index b52270d34..0837c51a6 100644 --- a/crates/worker/src/job.rs +++ b/crates/worker/src/job.rs @@ -224,6 +224,7 @@ impl PreparedInternalJob { run_id: uuid::Uuid::now_v7(), }, max_turns: engine_policy.max_turns.map(|n| n.get()), + compaction_budget: None, engine_configurator: Some(Box::new(move |engine| { crate::apply_worker_manifest(engine, &engine_policy); })), diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index a1901a27d..89cac0c61 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -156,11 +156,15 @@ impl WorkerPrompt { ]; } -/// Model-visible queued Ticket projection shared by Server and TUI backlog attention paths. +/// Model-visible Ticket projection shared by Server and TUI attention paths. #[derive(Debug, Clone, PartialEq, Eq, Serialize)] pub struct OrchestratorQueueAttentionTicket { resource_key: String, title: String, + assignment_id: Option, + input_event_id: Option, + waiting_reason: Option, + next_action: Option, } impl OrchestratorQueueAttentionTicket { @@ -175,8 +179,28 @@ impl OrchestratorQueueAttentionTicket { Ok(Self { resource_key, title: bounded_queue_attention_text(&title.into(), 240), + assignment_id: None, + input_event_id: None, + waiting_reason: None, + next_action: None, }) } + + /// Committed delegation/input identity, not a handling-status transition. + /// The receiving Worker must reread current authority before a new action. + pub fn with_assignment_input( + mut self, + assignment_id: &str, + input_event_id: &str, + waiting_reason: Option<&str>, + next_action: Option<&str>, + ) -> Self { + self.assignment_id = Some(bounded_queue_attention_text(assignment_id, 1024)); + self.input_event_id = Some(bounded_queue_attention_text(input_event_id, 1024)); + self.waiting_reason = waiting_reason.map(|text| bounded_queue_attention_text(text, 1024)); + self.next_action = next_action.map(|text| bounded_queue_attention_text(text, 1024)); + self + } } /// Shared model-visible context for every Orchestrator backlog attention renderer. @@ -200,7 +224,7 @@ impl OrchestratorQueueAttentionContext { } } -/// Prompt-catalog entries that must share the same backlog-attention body contract. +/// Prompt-catalog entries sharing a bounded Ticket resource-key projection. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum OrchestratorQueueAttentionPrompt { Server, @@ -796,7 +820,7 @@ mod tests { "ReviewMergeRequest", "CheckMergeRequestReadiness", "CompleteMergeRequest", - "CompleteTicket", + "TicketCaseUpdate", ] { assert!(combined.contains(operation), "missing {operation}"); } @@ -841,8 +865,11 @@ mod tests { .orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Tui, &context) .unwrap(); - assert_eq!(server, tui); - assert!(server.starts_with("Queued Tickets require attention:")); + assert!(server.starts_with("Assigned Tickets have unreceived input:")); + assert!(tui.starts_with("Assigned Tickets have input requiring attention:")); + assert!(tui.contains("no ready/Queue step is required")); + assert!(tui.contains("were omitted from this notice: 1")); + assert!(!tui.contains("T-21")); assert!(server.contains("- T-1 — Ticket 1 with control ")); assert!(!server.contains("T-21")); assert!(server.contains("were omitted from this notice: 1")); @@ -859,6 +886,28 @@ mod tests { } } + #[test] + fn assignment_attention_carries_input_identity_and_bounded_waiting_context() { + let ticket = OrchestratorQueueAttentionTicket::new("T-742", "Partial work") + .unwrap() + .with_assignment_input( + "assignment-a", + "answer-event", + Some("human\nanswer"), + Some(&"x".repeat(2048)), + ); + let context = OrchestratorQueueAttentionContext::new(vec![ticket]); + let output = PromptCatalog::builtins_only() + .unwrap() + .orchestrator_queue_attention(OrchestratorQueueAttentionPrompt::Server, &context) + .unwrap(); + assert!(output.contains("Delegation: assignment-a; input: answer-event.")); + assert!(output.contains("Waiting reason: human answer")); + assert!(output.contains(&format!("Next action: {}…", "x".repeat(1024)))); + assert!(!output.contains(&"x".repeat(1025))); + assert!(output.contains("no ready/Queue step is required")); + } + #[test] fn queue_attention_prompt_omits_truncation_text_for_complete_list() { let catalog = PromptCatalog::builtins_only().unwrap(); diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index 4fbff3406..6cf673681 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -334,19 +334,28 @@ mod tests { fn ticket_workflow_fixture( role: &str, mr_config: MergeRequestFeatureConfig, + ) -> (String, Vec) { + ticket_fixture_with_access( + role, + mr_config, + TicketFeatureAccess { + thread: true, + workflow: true, + ..Default::default() + }, + ) + } + + fn ticket_fixture_with_access( + role: &str, + mr_config: MergeRequestFeatureConfig, + access: TicketFeatureAccess, ) -> (String, Vec) { let client = Arc::new(TestWorkspaceHttpClient::new("workspace", "http://unused")); let mut tools = Vec::new(); let mut hooks = HookRegistryBuilder::default(); let report = FeatureRegistryBuilder::new() - .with_module(TicketFeature::new( - client.clone(), - TicketFeatureAccess { - thread: true, - workflow: true, - ..Default::default() - }, - )) + .with_module(TicketFeature::new(client.clone(), access)) .with_module(MergeRequestFeature::new(client, mr_config)) .install_into_pending(&mut tools, &mut hooks); assert!(!report.has_errors(), "{}", report.error_message()); @@ -384,12 +393,12 @@ mod tests { assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest")); let complete = tools .iter() - .find(|meta| meta.name == "CompleteTicket") + .find(|meta| meta.name == "TicketCaseUpdate") .unwrap(); assert!( complete .description - .contains("Merge Requests and approvals are not prerequisites") + .contains("Repository, Merge Request and approval evidence are not prerequisites") ); assert!(complete.description.contains("not real judgment")); assert!( @@ -461,12 +470,153 @@ mod tests { ); let complete = tools .iter() - .find(|meta| meta.name == "CompleteTicket") + .find(|meta| meta.name == "TicketCaseUpdate") .unwrap(); assert!( complete .description - .contains("return without conclusion need not call this tool") + .contains("Return without conclusion need not call this tool") + ); + } + } + + #[test] + fn effective_ticket_instructions_separate_case_handling_and_delegation_retries() { + for role in [ + "role.ticket_worker", + "role.coder", + "role.orchestrator", + "role.intake", + ] { + let (prompt, tools) = + ticket_workflow_fixture(role, MergeRequestFeatureConfig::default()); + for policy in [ + "only `open`", + "stable tag IDs, not display names", + "no ready step or separate Queue is required", + "Orchestrator's self-reported handling", + "Answers during waiting or ongoing handling continue the same assignment row", + "explicit reissue creates a new row/assignment_id", + "Respect an explicit user approval condition", + "done does not require unassignment", + "expected_last_event_id", + "Exact retries retain the same key and full input", + "Assign persistence, notification delivery, Runtime acceptance and actual work start", + "concrete API insufficiency", + ] { + assert!(prompt.contains(policy), "{role}: missing {policy}"); + } + let update = tools + .iter() + .find(|meta| meta.name == "TicketAssignmentTrackingUpdate") + .unwrap(); + for policy in [ + "self-reported", + "same row", + "new Assign row", + "complete tracking payload", + "different input with that key is rejected", + "reread current authority", + "exact retry receipts are not fresh observations", + ] { + assert!( + update.description.contains(policy), + "{role}: missing {policy}" + ); + } + assert!(!tools.iter().any(|meta| matches!( + meta.name.as_str(), + "TicketMarkReady" + | "TicketQueue" + | "TicketIntakeReady" + | "CompleteTicket" + | "TicketWorkflowState" + | "TicketClose" + ))); + if role == "role.orchestrator" { + assert!(prompt.contains("observed current `orchestrator_assignment_id`")); + assert!(prompt.contains( + "generic `WorkerSpawn` Ticket starts and existing-Worker assignment" + )); + } + } + } + + // Scripted policy/input fixtures: they do not run a model or certify its judgment. + #[test] + fn effective_return_wait_answer_and_close_fixtures_match_resolved_profile_tools_without_repo_or_mr() + { + let dir = tempfile::tempdir().unwrap(); + let resolved = manifest::ProfileResolver::new() + .with_workspace_base(dir.path()) + .resolve( + &manifest::ProfileSelector::parse_cli("builtin:orchestrator"), + manifest::ProfileResolveOptions::with_worker_name("orchestrator-fixture"), + ) + .unwrap(); + let config = resolved.manifest.feature.ticket; + // Omit MR feature for the no-repository/no-MR work fixture; profile selection + // makes it available, not mandatory to exercise for every Ticket. + let (prompt, tools) = ticket_fixture_with_access( + &resolved.manifest.engine.instruction, + MergeRequestFeatureConfig::default(), + TicketFeatureAccess { + authoring: config.authoring, + thread: config.thread, + intake: config.intake, + workflow: config.workflow, + }, + ); + assert!(!tools.iter().any(|meta| matches!( + meta.name.as_str(), + "OpenMergeRequest" | "ReviewMergeRequest" | "CompleteMergeRequest" + ))); + for (policy, name, input) in [ + ( + "Use `TicketOrchestratorAssign` for explicit initial delegation or reissue", + "TicketOrchestratorAssign", + serde_json::json!({"ticket":"T-743","principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"expected_assignment_id":null,"operation_id":"delegate-research"}), + ), + ( + "research returned to a human can be `open` + handling `done`", + "TicketAssignmentTrackingUpdate", + serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"return-options", "tracking":{"status":"done", "result":"Options returned", "next_action":"Human decision"}}), + ), + ( + "Respect an explicit user approval condition", + "TicketAssignmentTrackingUpdate", + serde_json::json!({"ticket":"T-743", "assignment_id":"delegation", "expected_last_event_id":"assigned", "operation_key":"wait-for-answer", "tracking":{"status":"waiting", "waiting_reason":"User requested confirmation first", "next_action":"Read the user's answer on this assignment"}}), + ), + ( + "use `TicketAssignmentAnswer` with that `assignment_id`", + "TicketAssignmentAnswer", + serde_json::json!({"ticket":"T-743","assignment_id":"delegation","expected_last_event_id":"waiting","operation_key":"answer-on-same-row","body":"Option A approved; continue within scope"}), + ), + ( + "No Merge Request or approval is a prerequisite for a generic Ticket decision", + "TicketCaseUpdate", + serde_json::json!({"ticket":"T-743", "expected_content_digest":"observed-digest", "expected_state":"open", "expected_closure_operation_key":null, "operation_key":"fulfilled-close", "action":{"action":"close", "reason":"completed"}, "explanation":"The whole bounded request is fulfilled and closure was requested"}), + ), + ] { + assert!(prompt.contains(policy), "missing {policy}"); + let meta = tools.iter().find(|meta| meta.name == name).unwrap(); + let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); + assert!(validator.is_valid(&input), "{name} rejected {input}"); + let required = meta.input_schema["required"].as_array().unwrap(); + assert!(!required.iter().any(|field| matches!( + field.as_str(), + Some( + "repository_key" + | "merge_request_id" + | "approval_event_id" + | "workdir_attachments" + ) + ))); + let mut forged = input; + forged["actor"] = serde_json::json!("workspace-orchestrator"); + assert!( + !validator.is_valid(&forged), + "model-supplied authority accepted" ); } } diff --git a/crates/worker/src/session_capture.rs b/crates/worker/src/session_capture.rs index 46e52c33c..3089fa660 100644 --- a/crates/worker/src/session_capture.rs +++ b/crates/worker/src/session_capture.rs @@ -1,7 +1,9 @@ //! Exploration domain for immutable Session captures, with no Workspace/Memory mutation capability. //! //! A capture is an ordered projection of committed entries; exploration excludes reasoning and -//! system prompts. Overview, search, bounded reads, and evidence conversion share `SessionEntryRef`. +//! system prompts, including Host compaction summaries and other derived context. Full inherited +//! summary input belongs to compaction, not public observation or Memory evidence. Overview, +//! search, bounded reads, and evidence conversion share `SessionEntryRef`. //! References identify entries in the selected Session and cannot be reused for another subject. //! Appends preserve existing entry identities. Sparse real user/assistant anchors and intervening //! entry counts index long histories. Search with an empty filter also provides bounded range lists. @@ -734,7 +736,9 @@ fn public_provenance(origin: &WorkerHistoryProvenance) -> SessionEntryProvenance } WorkerHistoryProvenance::ModelOutput { .. } => SessionEntryProvenance::ModelOutput, WorkerHistoryProvenance::ToolOutput { .. } => SessionEntryProvenance::ToolOutput, - WorkerHistoryProvenance::DerivedSummary => SessionEntryProvenance::DerivedSummary, + WorkerHistoryProvenance::CompactionSummary | WorkerHistoryProvenance::DerivedSummary => { + SessionEntryProvenance::DerivedSummary + } WorkerHistoryProvenance::LegacyUnknown => SessionEntryProvenance::LegacyUnknown, } } @@ -887,6 +891,60 @@ mod tests { ); } + #[test] + fn host_summary_remains_private_to_compaction_not_observation_or_memory_evidence() { + let entries = [ + ( + Item::system_message("host-summary-secret"), + WorkerHistoryProvenance::CompactionSummary, + ), + ( + Item::system_message("other-derived-secret"), + WorkerHistoryProvenance::DerivedSummary, + ), + ( + Item::system_message("raw-system-secret"), + WorkerHistoryProvenance::LegacyUnknown, + ), + ( + Item::reasoning("reasoning-secret"), + WorkerHistoryProvenance::LegacyUnknown, + ), + ] + .into_iter() + .map(|(item, origin)| crate::session_history::history_entry(item, origin)) + .collect::>(); + let refs = entries + .iter() + .map(|entry| SessionEntryRef::from_history_entry_id(&entry.annotation.entry_id)) + .collect::>(); + let capture = SessionCapture::from_history_entries("segment", entries); + assert!(capture.overview().is_empty()); + assert!( + capture + .search(&SearchOptions { + query: "secret".into(), + ..SearchOptions::default() + }) + .is_empty() + ); + assert!( + capture + .read(ReadSelector::EntryRange([0, 3]), ReadOptions::default()) + .entries + .is_empty() + ); + for reference in refs { + assert!(capture.evidence_for(reference.as_str()).is_none()); + assert!( + capture + .read(ReadSelector::Id(reference.as_str()), ReadOptions::default()) + .entries + .is_empty() + ); + } + } + #[test] fn overview_contains_user_and_assistant_only() { let view = SessionCapture::new( diff --git a/crates/worker/src/session_history.rs b/crates/worker/src/session_history.rs index f7ed7df8d..b5f7c1514 100644 --- a/crates/worker/src/session_history.rs +++ b/crates/worker/src/session_history.rs @@ -17,6 +17,8 @@ pub type WorkerHistoryProvenance = LoggedSessionHistoryOrigin; pub type SessionHistoryDerivation = LoggedHistoryDerivation; pub type WorkerSubjectSnapshot = LoggedWorkerSubject; +pub(crate) const COMPACTION_SUMMARY_HEADER: &str = "[Compacted context summary]\n\n"; + #[cfg(test)] pub(crate) fn test_logged_history_entry(item: impl Into) -> LoggedHistoryEntry { LoggedHistoryEntry { @@ -99,7 +101,10 @@ fn from_logged(entry: &LoggedHistoryEntry) -> HistoryEntry { + LogEntry::AnnotatedSegmentStart { + history: seed, + compacted_from, + .. + } => { history = seed.iter().map(from_logged).collect(); + // Older Host compactions always put this DerivedSummary system item + // first in the replacement seed. Only that trusted construction boundary + // may interpret the marker; never infer origin from appended/pasted text. + if compacted_from.is_some() + && let Some(first) = history.first_mut() + && first.annotation.origin == WorkerHistoryProvenance::DerivedSummary + && matches!( + &first.item, + Item::Message { + role: agen::Role::System, + .. + } + ) + && first + .item + .as_text() + .is_some_and(|text| text.starts_with(COMPACTION_SUMMARY_HEADER)) + { + first.annotation.origin = WorkerHistoryProvenance::CompactionSummary; + } } LogEntry::AnnotatedUserInput { history: input, .. } => { history.extend(input.iter().map(from_logged)) @@ -144,6 +173,79 @@ mod tests { use protocol::Segment; use session_store::LogEntry; + #[test] + fn restore_recognizes_only_legacy_host_compaction_seed_summary() { + let summary = history_entry( + Item::system_message(format!("{COMPACTION_SUMMARY_HEADER}host summary")), + WorkerHistoryProvenance::DerivedSummary, + ); + for compacted in [false, true] { + let seed = LogEntry::AnnotatedSegmentStart { + ts: 0, + session_id: SessionId::now_v7(), + system_prompt: None, + config: RequestConfig::default(), + history: vec![to_logged_history_entry(&summary)], + forked_from: None, + compacted_from: compacted.then(|| session_store::SegmentOrigin { + segment_id: SegmentId::now_v7(), + at_turn_index: 0, + }), + }; + let appended = LogEntry::AnnotatedUserInput { + ts: 1, + segments: vec![], + extensions: vec![], + history: vec![to_logged_history_entry(&summary)], + }; + let restored = restore_history_entries( + SessionId::now_v7(), + SegmentId::now_v7(), + &[seed, appended], + ) + .unwrap(); + assert_eq!( + restored[0].annotation.origin, + if compacted { + WorkerHistoryProvenance::CompactionSummary + } else { + WorkerHistoryProvenance::DerivedSummary + } + ); + assert_eq!( + restored[1].annotation.origin, + WorkerHistoryProvenance::DerivedSummary + ); + assert_eq!(restored[0].annotation.entry_id, summary.annotation.entry_id); + } + for item in [ + Item::user_message(format!("{COMPACTION_SUMMARY_HEADER}pasted summary")), + Item::system_message("[Session TaskStore snapshot] tasks"), + ] { + let seed = LogEntry::AnnotatedSegmentStart { + ts: 0, + session_id: SessionId::now_v7(), + system_prompt: None, + config: RequestConfig::default(), + history: vec![to_logged_history_entry(&history_entry( + item, + WorkerHistoryProvenance::DerivedSummary, + ))], + forked_from: None, + compacted_from: Some(session_store::SegmentOrigin { + segment_id: SegmentId::now_v7(), + at_turn_index: 0, + }), + }; + let restored = + restore_history_entries(SessionId::now_v7(), SegmentId::now_v7(), &[seed]).unwrap(); + assert_eq!( + restored[0].annotation.origin, + WorkerHistoryProvenance::DerivedSummary + ); + } + } + #[test] fn typed_flow_and_unknown_caller_input_round_trip_without_role_inference() { let session_id = SessionId::now_v7(); diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index a411b2ea3..e2d8008a0 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -732,6 +732,28 @@ pub struct WorkspaceWorkerDiscoveryRequest { #[derive(Debug, Clone)] pub enum WorkspaceServerOperation { + TicketAssignmentSet { + ticket_id: String, + role: server_api::TicketAssignmentRole, + request: server_api::SetTicketRoleAssignmentRequest, + }, + TicketAssignmentClear { + ticket_id: String, + role: server_api::TicketAssignmentRole, + query: server_api::ClearTicketRoleAssignmentQuery, + }, + TicketAssignmentsPage { + ticket_id: String, + query: server_api::TicketAssignmentsQuery, + }, + TicketAssignmentDiagnostics { + ticket_id: String, + query: server_api::TicketAssignmentDiagnosticsQuery, + }, + TicketAssignmentAnswer { + ticket_id: String, + request: server_api::TicketAssignmentAnswerRequest, + }, WorkerControlList, WorkerControlSpawn(server_api::CreateWorkspaceWorkerRequest), WorkerControlInput { @@ -787,6 +809,45 @@ fn encode_workspace_path_segment(value: &str) -> String { encoded } +fn workspace_server_query_request( + method: WorkspaceRequestMethod, + path: String, + query: &T, +) -> Result { + let query = serde_json::to_value(query) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?; + let fields = query.as_object().ok_or_else(|| { + WorkspaceClientError::Request("Workspace query must serialize to an object".into()) + })?; + let mut pairs = Vec::new(); + for (name, value) in fields { + let value = match value { + serde_json::Value::Null => continue, + serde_json::Value::String(value) => value.clone(), + serde_json::Value::Number(value) => value.to_string(), + _ => { + return Err(WorkspaceClientError::Request( + "Workspace query fields must be scalar".into(), + )); + } + }; + pairs.push(format!( + "{}={}", + encode_workspace_path_segment(name), + encode_workspace_path_segment(&value) + )); + } + Ok(WorkspaceRequest { + method, + path: if pairs.is_empty() { + path + } else { + format!("{path}?{}", pairs.join("&")) + }, + body: None, + }) +} + fn workspace_server_operation_request( workspace_id: &str, operation: WorkspaceServerOperation, @@ -794,6 +855,76 @@ fn workspace_server_operation_request( let workspace_id = encode_workspace_path_segment(workspace_id); let base = format!("/api/w/{workspace_id}"); match operation { + WorkspaceServerOperation::TicketAssignmentSet { + ticket_id, + role, + request, + } => { + let role = match role { + server_api::TicketAssignmentRole::Orchestrator => "orchestrator", + server_api::TicketAssignmentRole::Worker => "worker", + server_api::TicketAssignmentRole::Owner => "owner", + server_api::TicketAssignmentRole::Contributor => "contributor", + }; + Ok(WorkspaceRequest::json( + WorkspaceRequestMethod::Put, + format!( + "{base}/tickets/{}/assignments/{role}", + encode_workspace_path_segment(&ticket_id) + ), + serde_json::to_string(&request) + .map_err(|error| WorkspaceClientError::Request(error.to_string()))?, + )) + } + WorkspaceServerOperation::TicketAssignmentClear { + ticket_id, + role, + query, + } => { + let role = match role { + server_api::TicketAssignmentRole::Orchestrator => "orchestrator", + server_api::TicketAssignmentRole::Worker => "worker", + server_api::TicketAssignmentRole::Owner => "owner", + server_api::TicketAssignmentRole::Contributor => "contributor", + }; + workspace_server_query_request( + WorkspaceRequestMethod::Delete, + format!( + "{base}/tickets/{}/assignments/{role}", + encode_workspace_path_segment(&ticket_id) + ), + &query, + ) + } + WorkspaceServerOperation::TicketAssignmentsPage { ticket_id, query } => { + workspace_server_query_request( + WorkspaceRequestMethod::Get, + format!( + "/workspaces/{workspace_id}/tickets/{}/assignment-tracking/assignments", + encode_workspace_path_segment(&ticket_id) + ), + &query, + ) + } + WorkspaceServerOperation::TicketAssignmentDiagnostics { ticket_id, query } => { + workspace_server_query_request( + WorkspaceRequestMethod::Get, + format!( + "/workspaces/{workspace_id}/tickets/{}/assignment-diagnostics", + encode_workspace_path_segment(&ticket_id) + ), + &query, + ) + } + WorkspaceServerOperation::TicketAssignmentAnswer { ticket_id, request } => { + workspace_server_json_request( + format!( + "/workspaces/{workspace_id}/tickets/{}/assignment-answer", + encode_workspace_path_segment(&ticket_id) + ), + &request, + ) + } WorkspaceServerOperation::WorkerControlList => Ok(WorkspaceRequest::get(format!( "{base}/worker-control/workers" ))), @@ -2804,6 +2935,7 @@ pub struct Worker { /// manifest-derived lifecycle Features before controller startup. manifest_lifecycle_features_enabled: bool, interceptor_installed: bool, + compaction_service_budget: Option>, /// Shared compaction state (present when threshold is configured). compact_state: Option>, /// Per-LLM-request Usage tracker. Always present after construction. @@ -3148,6 +3280,7 @@ impl Worker { feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: true, interceptor_installed: false, + compaction_service_budget: None, compact_state: None, usage_tracker: Arc::new(UsageTracker::new()), metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), @@ -4188,6 +4321,24 @@ impl Worker { self.callback_socket.as_ref() } + pub(crate) fn install_compaction_budget( + &mut self, + budget: Option, + ) { + self.assert_hooks_open(); + self.compaction_service_budget = budget.map(|budget| { + Arc::new( + budget.with_request_shape( + self.engine() + .get_system_prompt() + .unwrap_or_default() + .to_owned(), + self.engine().tool_server_handle(), + ), + ) + }); + } + // --- Hook registration --- fn assert_hooks_open(&self) { @@ -4283,6 +4434,7 @@ impl Worker { self.log_writer.clone(), self.pending_committed_history.clone(), ) + .with_compaction_budget(self.compaction_service_budget.clone()) .with_notification_committer(self.notification_committer.clone()) .with_usage_tracker(self.usage_tracker.clone()) .with_metrics_tracker(self.metrics_tracker.clone()) @@ -6603,7 +6755,15 @@ impl Worker { let history_entries = self.session.history().entries(); let retain_from = cut.index.min(history_entries.len()); - let retained_history_entries = history_entries[retain_from..].to_vec(); + let initial_summary = initial_compaction_summary(history_entries)?; + // The old summary is consumed in full by the compaction service even when + // the cut falls before it. The replacement summary supersedes it: retaining + // it as well would duplicate inherited context on every compact. + let retained_history_entries = history_entries[retain_from..] + .iter() + .filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary) + .cloned() + .collect::>(); let mut retained_items = retained_history_entries .iter() .map(|entry| entry.item.clone()) @@ -6616,7 +6776,11 @@ impl Worker { project_compaction_attachments(&mut retained_items, default_workdir_alias.as_deref()); // The compactor explores summaries, text details, and ViewImage call arguments. It // never needs durable image bodies; relevant images are nominated with add_reference. - let mut entries_to_summarise = history_entries[..retain_from].to_vec(); + let mut entries_to_summarise = history_entries[..retain_from] + .iter() + .filter(|entry| entry.annotation.origin != WorkerHistoryProvenance::CompactionSummary) + .cloned() + .collect::>(); project_compactor_capture_attachments(&mut entries_to_summarise); let items_to_summarise = entries_to_summarise .iter() @@ -6681,11 +6845,12 @@ impl Worker { Vec::new() }; - // Input text fed to the compact worker. Includes the default - // references, current TaskStore snapshot, current TaskStore snapshot, and the (pruned) conversation text. + // The full Host summary is mandatory input, separate from the bounded + // prefix overview and current TaskStore snapshot. let task_snapshot_text = self.task_feature.snapshot_text(); let summary_input = build_summary_input( &items_to_summarise, + initial_summary.as_deref(), &default_refs, Some(task_snapshot_text.as_str()), SummaryInputOptions { @@ -6797,12 +6962,12 @@ impl Worker { input: summary_input.text.clone(), cache_key: Some(self.segment_id().to_string()), max_turns: worker_max_turns, + compaction_budget: Some(interceptor), engine_configurator: Some(Box::new(move |engine| { let tracker = tracker_for_engine; engine.on_usage(move |event| { tracker.record_usage(event); }); - engine.set_interceptor(interceptor); })), features, required_tools, @@ -6862,7 +7027,8 @@ impl Worker { Some( "You have not called `write_summary` yet. Deliver the structured \ summary now (Completed Tasks / Active Task / Key Decisions / \ - User Directives / Current Work) and nominate any files the next \ + User Directives / Current Work), preserving ongoing goals and user \ + directives from the initial summary and segment. Nominate any files the next \ session needs with `mark_read_required` for UTF-8 text or \ `add_reference` for images, binary files, and on-demand reads." .to_string(), @@ -6894,11 +7060,8 @@ impl Worker { .ok_or(WorkerError::CompactSummaryMissing)?; let mut summary_tokens = estimate_text_tokens(summary_text.len()); if summary_max_tokens > 0 && summary_tokens > summary_max_tokens { - let prompt = format!( - "Your `write_summary` output is too large (≈{summary_tokens} tokens; max \ - {summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \ - same five sections but making it concise. Target ≈{summary_target_tokens} tokens." - ); + let prompt = + summary_shrink_prompt(summary_tokens, summary_max_tokens, summary_target_tokens); handle .send(prompt) .await @@ -7007,8 +7170,10 @@ impl Worker { ); let mut compact_introduced_system_messages = Vec::with_capacity(2 + auto_read_messages.len() + reference_message.is_some() as usize); - let summary_message = - Item::system_message(format!("[Compacted context summary]\n\n{summary_text}")); + let summary_message = Item::system_message(format!( + "{}{summary_text}", + crate::session_history::COMPACTION_SUMMARY_HEADER + )); compact_introduced_system_messages.push(summary_message.clone()); compact_introduced_system_messages.extend(auto_read_messages.iter().cloned()); if let Some(msg) = reference_message.as_ref() { @@ -7065,8 +7230,19 @@ impl Worker { let compacted_history_entries = new_history .iter() .cloned() - .map(|item| { - if let Some((offset, original)) = original_entries[original_cursor..] + .enumerate() + .map(|(index, item)| { + if index == 0 { + HistoryEntry::new( + item, + new_history_metadata( + WorkerHistoryProvenance::CompactionSummary, + Some(SessionHistoryDerivation { + sources: derived_sources.clone(), + }), + ), + ) + } else if let Some((offset, original)) = original_entries[original_cursor..] .iter() .enumerate() .find(|(_, original)| original.item == item) @@ -7482,6 +7658,7 @@ where feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: true, interceptor_installed: false, + compaction_service_budget: None, compact_state: None, usage_tracker: Arc::new(UsageTracker::new()), metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), @@ -7821,6 +7998,7 @@ where feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: false, interceptor_installed: false, + compaction_service_budget: None, compact_state: None, usage_tracker: Arc::new(UsageTracker::new()), metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), @@ -7950,6 +8128,7 @@ where feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: true, interceptor_installed: false, + compaction_service_budget: None, compact_state: None, usage_tracker: Arc::new(UsageTracker::new()), metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), @@ -8335,6 +8514,7 @@ where feature_invocations: FeatureInvocationRegistry::default(), manifest_lifecycle_features_enabled: true, interceptor_installed: false, + compaction_service_budget: None, compact_state: None, usage_tracker: Arc::new(UsageTracker::new()), metrics_tracker: Arc::new(crate::compact::metrics_tracker::MetricsTracker::new()), @@ -8733,6 +8913,61 @@ fn build_related_file_message( ))) } +fn summary_shrink_prompt( + summary_tokens: u64, + summary_max_tokens: u64, + summary_target_tokens: u64, +) -> String { + format!( + "Your `write_summary` output is too large (≈{summary_tokens} tokens; max \ + {summary_max_tokens}). Rewrite it now with `write_summary`, preserving the \ + same five sections. Compress duplicate explanations and old work details \ + first; preserve ongoing goals and user directives from the initial summary \ + and segment, including scope, prohibitions, completion conditions and \ + exception-reporting duties. Apply explicit later changes/retractions and \ + retain uncertainty; do not replace the original request with recovery \ + procedures or recent progress. Target ≈{summary_target_tokens} tokens." + ) +} + +/// Select only the Host's typed summary, never a marker in ordinary content. +/// Invalid/duplicate Host summaries are a broken seed contract, not permission +/// to silently omit inherited instructions. +fn initial_compaction_summary( + entries: &[HistoryEntry], +) -> Result, WorkerError> { + let mut summaries = entries + .iter() + .filter(|entry| entry.annotation.origin == WorkerHistoryProvenance::CompactionSummary); + let Some(entry) = summaries.next() else { + return Ok(None); + }; + if summaries.next().is_some() { + return Err(WorkerError::InvalidState( + "multiple Host compaction summaries in active history".into(), + )); + } + if !matches!( + &entry.item, + Item::Message { + role: agen::Role::System, + .. + } + ) { + return Err(WorkerError::InvalidState( + "Host compaction summary must be a system message".into(), + )); + } + let body = entry + .item + .as_text() + .and_then(|text| text.strip_prefix(crate::session_history::COMPACTION_SUMMARY_HEADER)) + .ok_or_else(|| { + WorkerError::InvalidState("Host compaction summary header is missing".into()) + })?; + Ok(Some(body.to_string())) +} + #[derive(Debug, Clone, Copy)] struct SummaryInputOptions { overview_target_tokens: u64, @@ -8749,11 +8984,12 @@ struct SummaryInputBuild { deadline_fallback_used: bool, } -/// Build the compact worker's input: default-reference instructions, -/// the list of recently-touched files, task snapshot, -/// and a bounded overview rather than a prefix-wide transcript. +/// Build mandatory full Host-summary input plus a bounded prefix overview, +/// recently-touched files and current tasks. Overview budgets never apply to +/// the summary; the complete request is checked by the compaction interceptor. fn build_summary_input( items: &[Item], + initial_summary: Option<&str>, default_refs: &[PathBuf], task_snapshot: Option<&str>, options: SummaryInputOptions, @@ -8779,10 +9015,20 @@ fn build_summary_input( out.push_str(&format!( "Summarise this session into a structured summary of about {} tokens and \ nominate files the next session needs. The conversation below is a \ - bounded overview/index, not the full transcript. Use tools to inspect \ + bounded overview/index, not the full transcript. A separately supplied initial \ + compaction summary is complete, mandatory source material. Preserve ongoing \ + user goals and directives from it and from this segment's user messages; \ + confirm important wording with exploration rather than reconstructing the \ + request from recent progress. Retained tail entries remain verbatim in the \ + next session and are outside this prefix overview. Use tools to inspect \ current files when deciding auto-read/reference output.\n\n", options.summary_target_tokens )); + if let Some(summary) = initial_summary { + out.push_str("## Initial compaction summary (full text)\n"); + out.push_str(summary); + out.push_str("\n\n## End initial compaction summary\n\n"); + } if !default_refs.is_empty() { out.push_str( "These files were touched recently in this session. Use `read_file` \ @@ -8801,7 +9047,8 @@ fn build_summary_input( out.push_str( "## Current Session TaskStore\n\ This is the full current task list. Use it as source material for the \ - summary, especially active (pending/inprogress) tasks, but do not edit tasks \ + summary, especially active (pending/inprogress) tasks, but not as a \ + replacement for user goals, directives or completion conditions. Do not edit tasks \ from the compact worker.\n", ); out.push_str(task_snapshot); @@ -10081,6 +10328,7 @@ mod build_summary_prompt_tests { fn test_summary_input(items: &[Item]) -> String { build_summary_input( items, + None, &[], None, SummaryInputOptions { @@ -10093,6 +10341,72 @@ mod build_summary_prompt_tests { .text } + #[test] + fn compact_prompt_and_shrink_policy_prioritize_continuing_user_intent() { + // The builtin resource is the default model-visible compaction contract. + let prompt = include_str!("../../../resources/prompts/internal/compact_system.md"); + for policy in [ + "initial compaction summary in full", + "segment's user messages", + "prohibitions", + "completion conditions", + "explicit changes or retractions", + "Preserve uncertainty", + "retained tail stays verbatim", + "TaskStore", + ] { + assert!(prompt.contains(policy), "missing prompt policy: {policy}"); + } + let shrink = summary_shrink_prompt(2_000, 1_000, 500); + for policy in [ + "initial summary", + "and segment", + "Compress duplicate", + "prohibitions", + "completion conditions", + "exception-reporting duties", + "changes/retractions", + "uncertainty", + "recovery", + ] { + assert!(shrink.contains(policy), "missing shrink policy: {policy}"); + } + let input = test_summary_input(&[Item::user_message("keep original scope")]); + assert!(input.contains("Preserve ongoing user goals and directives")); + assert!(input.contains("Retained tail entries remain verbatim")); + } + + #[test] + fn malformed_or_duplicate_host_summaries_fail_instead_of_omitting_context() { + use crate::session_history::history_entry; + let valid = history_entry( + Item::system_message(format!( + "{}body", + crate::session_history::COMPACTION_SUMMARY_HEADER + )), + WorkerHistoryProvenance::CompactionSummary, + ); + assert_eq!( + initial_compaction_summary(std::slice::from_ref(&valid)) + .unwrap() + .as_deref(), + Some("body") + ); + assert!(initial_compaction_summary(&[valid.clone(), valid]).is_err()); + for item in [ + Item::user_message("pasted"), + Item::system_message("missing marker"), + ] { + assert!( + initial_compaction_summary(&[history_entry( + item, + WorkerHistoryProvenance::CompactionSummary + )]) + .is_err() + ); + } + } + #[test] fn retained_images_become_on_demand_related_files() { let mut items = vec![ @@ -10193,6 +10507,7 @@ mod build_summary_prompt_tests { let items = vec![Item::user_message("x".repeat(4_000))]; let built = build_summary_input( &items, + None, &[], None, SummaryInputOptions { @@ -10212,6 +10527,7 @@ mod build_summary_prompt_tests { let items = vec![Item::user_message("x".repeat(4_000))]; let built = build_summary_input( &items, + None, &[], None, SummaryInputOptions { diff --git a/crates/worker/tests/compact_events_test.rs b/crates/worker/tests/compact_events_test.rs index 1a1fdc3eb..072f857c3 100644 --- a/crates/worker/tests/compact_events_test.rs +++ b/crates/worker/tests/compact_events_test.rs @@ -133,6 +133,7 @@ struct MockClient { responses: Arc>>, call_count: Arc, observer: Arc>>, + requests: Arc>>, } impl MockClient { @@ -141,9 +142,14 @@ impl MockClient { responses: Arc::new(responses), call_count: Arc::new(AtomicUsize::new(0)), observer: Arc::new(Mutex::new(None)), + requests: Arc::new(Mutex::new(Vec::new())), } } + fn requests(&self) -> Vec { + self.requests.lock().unwrap().clone() + } + fn set_observer(&self, observer: impl Fn(usize) + Send + Sync + 'static) { *self.observer.lock().unwrap() = Some(Arc::new(observer)); } @@ -157,9 +163,10 @@ impl LlmClient for MockClient { async fn stream( &self, - _request: Request, + request: Request, ) -> Result> + Send>>, ClientError> { + self.requests.lock().unwrap().push(request); let count = self.call_count.fetch_add(1, Ordering::SeqCst); if let Some(observer) = self.observer.lock().unwrap().clone() { observer(count); @@ -2065,3 +2072,732 @@ async fn controller_compact_method_publishes_progress_and_clear() { let command = protocol::WorkerCommandEnvelope::new(2); let _ = handle.send(Method::Shutdown { command }).await; } + +const INITIAL_SUMMARY_HEADING: &str = "## Initial compaction summary (full text)\n"; +const OVERVIEW_HEADING: &str = "## Conversation overview/index\n"; +const SUMMARY_MARKER: &str = "[Compacted context summary]\n\n"; + +// These tests prove transport and durable replacement policy, not whether a live +// model preserves meaning. All write_summary outputs below are predetermined. +struct SummaryInputFixture { + worker: Worker, + _store_dir: tempfile::TempDir, +} + +impl SummaryInputFixture { + async fn new(manifest_toml: &str, client: MockClient) -> Self { + let store_dir = tempfile::tempdir().unwrap(); + let store = CombinedStore::new( + FsStore::new(store_dir.path()).unwrap(), + FsWorkerStore::new(store_dir.path().join("pods")).unwrap(), + ); + let engine = + Engine::<_, agen::state::Mutable, worker::SessionHistoryMetadata>::new_annotated( + client, + ); + let mut worker = Worker::new( + worker::WorkerManifest::from_toml(manifest_toml).unwrap(), + engine, + store, + worker::WorkerWorkspaceContext::no_workspace(), + worker::WorkerFilesystemAuthority::None, + worker::Scope::empty(), + ) + .await + .unwrap(); + worker.enable_worker_metadata_write_through().unwrap(); + worker.attach_log_writer(Arc::new(worker.log_writer_handle())); + Self { + worker, + _store_dir: store_dir, + } + } + + // Seed the persisted boundary, then exercise the public rewind API's shared + // typed history restore path. No production client or global allocation is + // needed. This is restoration coverage, not a cold Runtime restart test. + async fn restore_seed( + &mut self, + history: Vec, + compacted: bool, + ) { + self.worker + .run_text("fixture rewind boundary") + .await + .unwrap(); + let session_id = self.worker.session_id(); + let segment_id = self.worker.segment_id(); + let mut records = self + .worker + .store() + .read_all(session_id, segment_id) + .unwrap(); + let start = records + .iter_mut() + .find(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart { .. })) + .unwrap(); + match start { + LogEntry::AnnotatedSegmentStart { + history: seed, + compacted_from, + .. + } => { + *seed = history; + *compacted_from = compacted.then(|| session_store::SegmentOrigin { + segment_id: session_store::new_segment_id(), + at_turn_index: 0, + }); + } + _ => unreachable!(), + } + self.worker + .store() + .create_segment(session_id, segment_id, &records) + .unwrap(); + let snapshot = session_store::public_snapshot::project_current_session_snapshot(&records); + let target = snapshot + .entries + .iter() + .rev() + .find(|entry| { + matches!( + entry.data, + protocol::SessionSnapshotEntryData::UserInput { .. } + ) + }) + .expect("fixture rewind boundary must remain a public user input"); + self.worker + .rewind_to(target.entry_id.clone()) + .await + .unwrap(); + } +} + +fn summary_entry( + item: Item, + origin: session_store::LoggedSessionHistoryOrigin, +) -> session_store::LoggedHistoryEntry { + let mut entry = annotated(item); + entry.metadata.origin = origin; + entry +} + +fn long_summary(label: &str) -> String { + format!( + "## Completed Tasks\n{label}: investigated the transport boundary.\n\n\ + ## Active Task\nKeep the original user's completion conditions.\n{}\n\ + ## Key Decisions\nDo not infer provenance from pasted markers.\n\n\ + ## User Directives\n{label}_LOAD_BEARING_SENTINEL_AT_END\n\n\ + ## Current Work\nContinue the requested implementation.", + "Historical constraints must remain explicit, including the original acceptance criteria.\n" + .repeat(70) + ) +} + +fn recent_history(count: usize) -> Vec { + (0..count) + .flat_map(|index| { + [ + annotated(Item::user_message(format!( + "recent-user-{index}: {}", + "recent progress is not a replacement for the original directive. ".repeat(40) + ))), + annotated(Item::assistant_message(format!("recent-assistant-{index}"))), + ] + }) + .collect() +} + +fn first_compactor_input(request: &Request) -> String { + request + .items + .iter() + .find_map(|item| match item { + Item::Message { + role: agen::Role::User, + .. + } => item + .as_text() + .filter(|text| text.contains(OVERVIEW_HEADING)), + _ => None, + }) + .expect("the first compactor request must carry its overview input") + .to_string() +} + +fn assert_full_initial_summary(input: &str, summary: &str) { + let heading_at = input + .find(INITIAL_SUMMARY_HEADING) + .expect("full summary heading"); + let overview_at = input.find(OVERVIEW_HEADING).expect("overview heading"); + let summary_at = heading_at + INITIAL_SUMMARY_HEADING.len(); + assert!(summary_at < overview_at); + assert!( + input[summary_at..].starts_with(summary), + "summary must be verbatim" + ); + assert_eq!( + input.matches(summary).count(), + 1, + "summary must not be duplicated" + ); + assert!(!input[overview_at..].contains(summary)); + let opening = summary.chars().take(100).collect::(); + assert!( + !input[overview_at..].contains(&opening), + "overview must not carry even a clipped summary" + ); + assert!(!input[overview_at..].contains("LOAD_BEARING_SENTINEL_AT_END")); +} + +fn replacement_seed( + worker: &Worker, +) -> Vec { + worker + .store() + .read_all(worker.session_id(), worker.segment_id()) + .unwrap() + .into_iter() + .find_map(|entry| match entry { + LogEntry::AnnotatedSegmentStart { history, .. } => Some(history), + _ => None, + }) + .expect("replacement SegmentStart") +} + +#[tokio::test] +async fn full_initial_summary_survives_small_overview_budgets_and_both_retention_extremes() { + use session_store::LoggedSessionHistoryOrigin::CompactionSummary; + + let inherited = long_summary("original"); + assert!(inherited.chars().count() > 4_000); + for retained in [0, 1_000_000] { + for (warning, deadline) in [(0, 0), (1, 0), (1, 1)] { + let manifest = MANUAL_ONLY_MANIFEST_TOML.replace( + "[compaction]", + &format!( + "[compaction]\noverview_target_tokens = 1\noverview_warning_tokens = {warning}\noverview_deadline_tokens = {deadline}" + ), + ); + let client = MockClient::new(vec![ + single_text_events("fixture"), + write_summary_tool_use_events("replace", "new summary"), + single_text_events("done"), + ]); + let observed = client.clone(); + let mut fixture = SummaryInputFixture::new(&manifest, client).await; + let mut seed = vec![summary_entry( + Item::system_message(format!("{SUMMARY_MARKER}{inherited}")), + CompactionSummary, + )]; + let recent = recent_history(40); + seed.extend(recent.clone()); + fixture.restore_seed(seed, true).await; + let (tx, mut rx) = broadcast::channel::(64); + fixture.worker.attach_alerter(worker::Alerter::new(tx)); + + fixture.worker.compact(retained).await.unwrap(); + + let requests = observed.requests(); + assert_eq!(requests.len(), 3); + let input = first_compactor_input(&requests[1]); + assert_full_initial_summary(&input, &inherited); + let alerts = drain(&mut rx); + let has_alert = |snippet: &str| { + alerts.iter().any(|event| { + matches!(event, Event::Alert(alert) + if alert.source == protocol::AlertSource::Compactor + && alert.level == protocol::AlertLevel::Warn + && alert.message.contains(snippet)) + }) + }; + assert_eq!(has_alert("larger than expected"), warning != 0); + assert_eq!(has_alert("using coarse fallback"), deadline != 0); + let replacement = replacement_seed(&fixture.worker); + assert_eq!( + replacement + .iter() + .filter(|entry| entry.metadata.origin == CompactionSummary) + .count(), + 1 + ); + assert!(replacement.iter().all(|entry| { + !Item::from(entry.item.clone()) + .as_text() + .is_some_and(|text| text.contains(&inherited)) + })); + for entry in &recent { + assert_eq!( + replacement + .iter() + .any(|retained_entry| retained_entry == entry), + retained != 0, + "ordinary retained entries must keep their item and metadata" + ); + } + let metrics = session_metrics::read_session_metrics( + fixture.worker.store(), + fixture.worker.session_id(), + ) + .unwrap(); + let overview_tokens = metrics + .iter() + .find(|record| record.metric.name == "compact.overview_tokens") + .and_then(|record| record.metric.value) + .unwrap(); + assert!( + overview_tokens < inherited.len().div_ceil(4) as f64, + "overview accounting must exclude the inherited full summary" + ); + } + } +} + +#[tokio::test] +async fn pasted_markers_and_other_derived_context_remain_ordinary_overview_material() { + use session_store::LoggedSessionHistoryOrigin::{ + CompactionSummary, DerivedSummary, LegacyUnknown, + }; + + for retained in [0, 1_000_000] { + let client = MockClient::new(vec![ + single_text_events("fixture"), + write_summary_tool_use_events("replace", "replacement"), + single_text_events("done"), + ]); + let observed = client.clone(); + let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await; + let ordinary = vec![ + summary_entry( + Item::system_message("[Session TaskStore snapshot]\nactive task"), + DerivedSummary, + ), + summary_entry( + Item::system_message("auto-read file source material"), + DerivedSummary, + ), + summary_entry( + Item::system_message(format!("{SUMMARY_MARKER}pasted system marker")), + LegacyUnknown, + ), + summary_entry( + Item::user_message(format!("{SUMMARY_MARKER}pasted user marker")), + LegacyUnknown, + ), + summary_entry( + Item::system_message(format!("{SUMMARY_MARKER}later derived marker")), + DerivedSummary, + ), + ]; + fixture.restore_seed(ordinary.clone(), true).await; + assert!( + fixture + .worker + .session_history() + .entries() + .iter() + .all(|entry| entry.annotation.origin != CompactionSummary) + ); + + fixture.worker.compact(retained).await.unwrap(); + + let input = first_compactor_input(&observed.requests()[1]); + assert!(!input.contains(INITIAL_SUMMARY_HEADING)); + let overview = input.split_once(OVERVIEW_HEADING).unwrap().1; + let replacement = replacement_seed(&fixture.worker); + for entry in &ordinary { + let text = Item::from(entry.item.clone()) + .as_text() + .unwrap() + .to_string(); + assert_eq!( + overview.contains(&text), + retained == 0, + "ordinary overview source: {text}" + ); + assert_eq!( + replacement + .iter() + .any(|retained_entry| retained_entry == entry), + retained != 0, + "other derived context and pasted markers must follow ordinary retention" + ); + } + } +} + +#[tokio::test] +async fn restored_legacy_summary_requires_compacted_lineage_first_position_origin_role_and_marker() +{ + use session_store::LoggedSessionHistoryOrigin::{ + CompactionSummary, DerivedSummary, LegacyUnknown, + }; + + let inherited = long_summary("legacy"); + // Each false case lacks one trusted construction condition; marker text + // alone must never promote ordinary context into inherited Host material. + for case in [ + "trusted", + "no-lineage", + "not-first", + "unknown-origin", + "user-role", + "no-marker", + ] { + let client = MockClient::new(vec![ + single_text_events("fixture"), + write_summary_tool_use_events("replace", "replacement"), + single_text_events("done"), + ]); + let observed = client.clone(); + let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await; + let text = if case == "no-marker" { + inherited.clone() + } else { + format!("{SUMMARY_MARKER}{inherited}") + }; + let item = if case == "user-role" { + Item::user_message(text) + } else { + Item::system_message(text) + }; + let origin = if case == "unknown-origin" { + LegacyUnknown + } else { + DerivedSummary + }; + let candidate = summary_entry(item, origin); + let candidate_id = candidate.metadata.entry_id.clone(); + let mut history = vec![candidate]; + if case == "not-first" { + history.insert( + 0, + annotated(Item::system_message("unrelated first system context")), + ); + } + history.extend(recent_history(20)); + fixture.restore_seed(history, case != "no-lineage").await; + let restored = fixture + .worker + .session_history() + .entries() + .iter() + .find(|entry| entry.annotation.entry_id == candidate_id) + .unwrap(); + assert_eq!( + restored.annotation.origin == CompactionSummary, + case == "trusted", + "{case}" + ); + // A restore-time upgrade is an in-memory interpretation of the trusted + // seed; it must not silently rewrite the historical durable record. + assert!( + replacement_seed(&fixture.worker) + .iter() + .all(|entry| entry.metadata.origin != CompactionSummary) + ); + + fixture.worker.compact(0).await.unwrap(); + + let input = first_compactor_input(&observed.requests()[1]); + if case == "trusted" { + assert_full_initial_summary(&input, &inherited); + } else { + assert!(!input.contains(INITIAL_SUMMARY_HEADING), "{case}"); + } + } +} + +#[tokio::test] +async fn three_chained_compactions_transport_each_complete_predecessor_without_retaining_old_summaries() + { + use session_store::LoggedSessionHistoryOrigin::CompactionSummary; + + let original_goal = + "CHAIN_ORIGINAL_USER_GOAL: finish only after all explicit acceptance conditions hold"; + // Every predetermined output keeps the original directive. The assertions + // prove that complete predecessors reach later requests, not that a model + // chose to preserve this directive on its own. + let summaries = (0..3) + .map(|index| { + format!( + "{}\n{original_goal}", + long_summary(&format!("generation-{index}")) + ) + }) + .collect::>(); + let initial = format!("{}\n{original_goal}", long_summary("initial")); + let mut responses = vec![single_text_events("seed response")]; + for (index, summary) in summaries.iter().enumerate() { + responses.push(single_text_events( + "acknowledge new directive and temporary recovery", + )); + responses.push(write_summary_tool_use_events( + &format!("summary-{index}"), + summary, + )); + responses.push(single_text_events("done")); + } + let client = MockClient::new(responses); + let observed = client.clone(); + let mut fixture = SummaryInputFixture::new(MANUAL_ONLY_MANIFEST_TOML, client).await; + fixture + .restore_seed( + vec![summary_entry( + Item::system_message(format!("{SUMMARY_MARKER}{initial}")), + CompactionSummary, + )], + true, + ) + .await; + let original_session = fixture.worker.session_id(); + let mut predecessor = fixture.worker.segment_id(); + for (index, summary) in summaries.iter().enumerate() { + let new_directive = format!( + "NEW_USER_DIRECTIVE_{index}: do not merge. Temporarily recover the interrupted work and collect SubWorkers; this does not retract the original goal." + ); + fixture.worker.run_text(&new_directive).await.unwrap(); + let first_request = observed.requests().len(); + fixture.worker.compact(0).await.unwrap(); + let input = first_compactor_input(&observed.requests()[first_request]); + let prior = if index == 0 { + &initial + } else { + &summaries[index - 1] + }; + assert_full_initial_summary(&input, prior); + assert!(input.contains(original_goal)); + assert!(input.contains(&new_directive)); + let seed = replacement_seed(&fixture.worker); + let inherited = seed + .iter() + .filter(|entry| entry.metadata.origin == CompactionSummary) + .collect::>(); + assert_eq!(inherited.len(), 1); + assert_eq!( + Item::from(inherited[0].item.clone()).as_text().unwrap(), + format!("{SUMMARY_MARKER}{summary}") + ); + for old in &summaries[..index] { + assert!( + fixture + .worker + .history() + .iter() + .all(|item| !item.as_text().is_some_and(|text| text.contains(old))) + ); + } + let records = fixture + .worker + .store() + .read_all(original_session, fixture.worker.segment_id()) + .unwrap(); + assert!( + records + .iter() + .any(|entry| matches!(entry, LogEntry::AnnotatedSegmentStart { + compacted_from: Some(origin), .. + } if origin.segment_id == predecessor)) + ); + let restored = session_store::restore( + fixture.worker.store(), + original_session, + fixture.worker.segment_id(), + ) + .unwrap(); + assert!(restored.history.iter().any(|item| { + item.as_text() + .is_some_and(|text| text == format!("{SUMMARY_MARKER}{summary}")) + })); + predecessor = fixture.worker.segment_id(); + } + assert_eq!(observed.requests().len(), 10); +} + +#[tokio::test] +async fn inherited_summary_counts_against_hard_input_limit_and_failure_does_not_activate() { + use session_store::LoggedSessionHistoryOrigin::CompactionSummary; + + let manifest = MANUAL_ONLY_MANIFEST_TOML.replace( + "[compaction]", + "[compaction]\nworker_context_max_tokens = 10000\nfinish_warning_remaining_tokens = 0\nfinal_reserve_tokens = 0\noverview_target_tokens = 1", + ); + for include_summary in [false, true] { + let client = MockClient::new(vec![ + single_text_events("fixture"), + write_summary_tool_use_events("replace", "replacement"), + single_text_events("done"), + ]); + let observed = client.clone(); + let mut fixture = SummaryInputFixture::new(&manifest, client).await; + let summary = + "Preserve the user's goal and every explicit completion condition.\n".repeat(3_000); + assert!( + agen::token_counter::total_tokens(&[Item::user_message(&summary)], &[]).tokens > 10_000 + ); + let mut history = Vec::new(); + if include_summary { + history.push(summary_entry( + Item::system_message(format!("{SUMMARY_MARKER}{summary}")), + CompactionSummary, + )); + } + history.extend(recent_history(20)); + fixture.restore_seed(history, true).await; + let source = fixture.worker.segment_id(); + let source_history = fixture.worker.history(); + let result = fixture.worker.compact(0).await; + if include_summary { + let error = result.expect_err("mandatory input over hard cap must fail, not truncate"); + assert!( + matches!(error, WorkerError::CompactCancelled) + || error.to_string().contains("input occupancy"), + "unexpected hard-cap failure: {error:?}" + ); + assert_eq!( + observed.requests().len(), + 1, + "no compactor request may cross the hard cap" + ); + assert_eq!(fixture.worker.segment_id(), source); + assert_eq!(fixture.worker.history(), source_history); + assert_eq!( + fixture + .worker + .store() + .read_by_name("test-worker") + .unwrap() + .unwrap() + .active + .unwrap() + .segment_id, + Some(source) + ); + assert_eq!( + fixture + .worker + .store() + .list_segments(fixture.worker.session_id()) + .unwrap() + .len(), + 1 + ); + } else { + result.expect("the same bounded overview without inherited input fits the cap"); + assert_eq!(observed.requests().len(), 3); + assert_ne!(fixture.worker.segment_id(), source); + } + } +} + +#[tokio::test] +async fn summary_output_still_over_hard_max_after_rewrite_fails_without_activation() { + let oversized = long_summary("oversized"); + let manifest = MANUAL_ONLY_MANIFEST_TOML.replace( + "[compaction]", + "[compaction]\nsummary_target_tokens = 50\nsummary_max_tokens = 100", + ); + let client = MockClient::new(vec![ + single_text_events("seed"), + write_summary_tool_use_events("first-output", &oversized), + single_text_events("done"), + write_summary_tool_use_events("rewrite-output", &oversized), + single_text_events("done"), + ]); + let observed = client.clone(); + let mut fixture = SummaryInputFixture::new(&manifest, client).await; + fixture.worker.run_text("user goal").await.unwrap(); + let source = fixture.worker.segment_id(); + let history = fixture.worker.history(); + + let error = fixture.worker.compact(0).await.unwrap_err(); + + assert!(matches!( + error, + WorkerError::CompactSummaryTooLarge { max: 100, .. } + )); + assert_eq!(fixture.worker.segment_id(), source); + assert_eq!(fixture.worker.history(), history); + assert_eq!( + fixture + .worker + .store() + .read_by_name("test-worker") + .unwrap() + .unwrap() + .active + .unwrap() + .segment_id, + Some(source) + ); + assert_eq!( + fixture + .worker + .store() + .list_segments(fixture.worker.session_id()) + .unwrap() + .len(), + 1 + ); + assert!( + observed + .requests() + .iter() + .any(|request| request.items.iter().any(|item| item + .as_text() + .is_some_and(|text| text.contains("Rewrite it now with `write_summary`")))) + ); +} + +#[tokio::test] +async fn budget_warning_is_durable_and_keeps_full_summary_in_first_model_request() { + use session_store::LoggedSessionHistoryOrigin::CompactionSummary; + let manifest = MANUAL_ONLY_MANIFEST_TOML.replace( + "[compaction]", + "[compaction]\nworker_context_max_tokens = 10000\nfinish_warning_remaining_tokens = 9999\nfinal_reserve_tokens = 9999", + ); + let client = MockClient::new(vec![ + single_text_events("fixture"), + write_summary_tool_use_events("summary", "replacement"), + single_text_events("done"), + ]); + let observed = client.clone(); + let mut fixture = SummaryInputFixture::new(&manifest, client).await; + let inherited = long_summary("budget-warning"); + fixture + .restore_seed( + vec![summary_entry( + Item::system_message(format!("{SUMMARY_MARKER}{inherited}")), + CompactionSummary, + )], + true, + ) + .await; + let (tx, mut rx) = broadcast::channel::(128); + fixture.worker.attach_alerter(worker::Alerter::new(tx)); + fixture.worker.compact(0).await.unwrap(); + let requests = observed.requests(); + assert_full_initial_summary(&first_compactor_input(&requests[1]), &inherited); + let warning = requests[1] + .items + .iter() + .filter_map(Item::as_text) + .find(|text| text.starts_with("[Compact worker budget warning]")) + .expect("normal internal Worker must preserve the budget interceptor warning"); + assert!(warning.contains("full initial summary and segment")); + assert!(warning.contains("changes/retractions")); + assert!( + requests[2] + .items + .iter() + .filter_map(Item::as_text) + .any(|text| text == warning), + "warning must remain in history, not be a transient request insertion" + ); + assert!( + drain(&mut rx) + .iter() + .any(|event| matches!(event, Event::Alert(alert) + if alert.message.contains("compact worker context budget is low"))) + ); +} diff --git a/crates/workspace-server/src/assignment_delivery.rs b/crates/workspace-server/src/assignment_delivery.rs new file mode 100644 index 000000000..73c9197b9 --- /dev/null +++ b/crates/workspace-server/src/assignment_delivery.rs @@ -0,0 +1,484 @@ +//! Durable operational delivery and startup fencing, independent of Ticket handling. +//! +//! All delivery triggers use the same pending-input scan: current actionable delegation, +//! committed input origin and open case, never Queue/open state alone. Waiting is not a +//! resend reason; comments, self-reports and case/routing history are not new requests. +//! Migration preserves evidence but creates origins only for reconciled assignment-specific +//! legacy Queue fences. Unknown/history/done/released assignments are not fresh work. +//! +//! Reserve, prepare and acknowledge recheck delegation, singleton and live binding under +//! IMMEDIATE ownership. Persist the entire rendered request before Runtime I/O, then reuse +//! its request ID and content on response loss. Send outside the transaction; acknowledgement +//! proves Runtime acceptance, not model consideration, execution, handling or fulfillment. +//! Async callers offload whole helpers, never split authorization/receipt/CAS/audit across awaits. +//! +//! Orchestrator starts require the observed assignment and the request's authenticated binding, +//! not a newly resolved binding or model role. Saved start fences extend existing allocation +//! receipts through reserve/retry, bind, finalization and recovery. Unknown external outcomes +//! retain resource fences for reconciliation, not a second start. Manual authorized starts +//! omit delegation; unresolved dependencies are diagnostics, not blanket start gates. +//! Clear/reassign does not stop or transfer running work; reopen alone revives no old delegation. +//! Immutable operational receipts are deleted only by the guarded Workspace purge. +use super::*; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TicketAssignmentDelivery { + pub ticket_id: String, + pub assignment_id: String, + pub input_event_id: String, + pub delivery_id: String, + pub request_id: String, + pub waiting_reason: Option, + pub next_action: Option, +} + +pub(super) const SCHEMA_SQL: &str = r#" +CREATE TABLE ticket_assignment_delivery_origins ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, assignment_id TEXT NOT NULL, + input_event_id TEXT NOT NULL, after_ticket_event_index INTEGER NOT NULL, + PRIMARY KEY(workspace_id,assignment_id), + FOREIGN KEY(workspace_id,ticket_id,assignment_id) REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) +); +CREATE TABLE ticket_assignment_deliveries ( + workspace_id TEXT NOT NULL, assignment_id TEXT NOT NULL, input_event_id TEXT NOT NULL, + delivery_id TEXT NOT NULL, payload_json TEXT NOT NULL CHECK(json_valid(payload_json)), + reserved_source_json TEXT NOT NULL CHECK(json_valid(reserved_source_json)), + PRIMARY KEY(workspace_id,assignment_id,input_event_id), UNIQUE(workspace_id,delivery_id), + FOREIGN KEY(workspace_id,assignment_id) REFERENCES ticket_worker_assignments(workspace_id,assignment_id) +); +CREATE TABLE ticket_assignment_delivery_requests ( + workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL, content TEXT NOT NULL, + PRIMARY KEY(workspace_id,delivery_id), + FOREIGN KEY(workspace_id,delivery_id) REFERENCES ticket_assignment_deliveries(workspace_id,delivery_id) +); +CREATE TRIGGER ticket_assignment_delivery_request_no_update BEFORE UPDATE ON ticket_assignment_delivery_requests +BEGIN SELECT RAISE(ABORT,'Assignment delivery request is immutable'); END; +CREATE TRIGGER ticket_assignment_delivery_request_no_delete BEFORE DELETE ON ticket_assignment_delivery_requests +BEGIN SELECT RAISE(ABORT,'Assignment delivery request is immutable'); END; +CREATE TABLE ticket_assignment_delivery_acknowledgements ( + workspace_id TEXT NOT NULL, delivery_id TEXT NOT NULL, + source_json TEXT NOT NULL CHECK(json_valid(source_json)), PRIMARY KEY(workspace_id,delivery_id), + FOREIGN KEY(workspace_id,delivery_id) REFERENCES ticket_assignment_deliveries(workspace_id,delivery_id) +); +CREATE TABLE ticket_assignment_operation_fences ( + workspace_id TEXT NOT NULL, operation_id TEXT NOT NULL, orchestrator_assignment_id TEXT NOT NULL, + runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, agent_key TEXT NOT NULL, + authentication_binding_id TEXT NOT NULL, source_json TEXT NOT NULL CHECK(json_valid(source_json)), + PRIMARY KEY(workspace_id,operation_id), + FOREIGN KEY(workspace_id,operation_id) REFERENCES ticket_assignment_operations(workspace_id,operation_id), + FOREIGN KEY(workspace_id,orchestrator_assignment_id) REFERENCES ticket_worker_assignments(workspace_id,assignment_id) +); +CREATE TRIGGER ticket_assignment_delivery_origin_insert +AFTER INSERT ON typed_ticket_assignment_tracking_history +WHEN json_extract(NEW.source_context_json,'$.kind')='assignment' +BEGIN + INSERT INTO ticket_assignment_delivery_origins + VALUES(NEW.workspace_id,NEW.ticket_id,NEW.assignment_id,'assignment-event:' || NEW.event_id, + COALESCE((SELECT MAX(event_index) FROM typed_ticket_events + WHERE workspace_id=NEW.workspace_id AND ticket_id=NEW.ticket_id),-1)); +END; +CREATE TRIGGER ticket_assignment_delivery_payload_no_update BEFORE UPDATE ON ticket_assignment_deliveries +BEGIN SELECT RAISE(ABORT,'Assignment delivery payload is immutable'); END; +CREATE TRIGGER ticket_assignment_delivery_payload_no_delete BEFORE DELETE ON ticket_assignment_deliveries +BEGIN SELECT RAISE(ABORT,'Assignment delivery payload is immutable'); END; +CREATE TRIGGER ticket_assignment_delivery_ack_no_update BEFORE UPDATE ON ticket_assignment_delivery_acknowledgements +BEGIN SELECT RAISE(ABORT,'Assignment delivery acknowledgement is immutable'); END; +CREATE TRIGGER ticket_assignment_delivery_ack_no_delete BEFORE DELETE ON ticket_assignment_delivery_acknowledgements +BEGIN SELECT RAISE(ABORT,'Assignment delivery acknowledgement is immutable'); END; +CREATE TRIGGER ticket_assignment_operation_fence_no_update BEFORE UPDATE ON ticket_assignment_operation_fences +BEGIN SELECT RAISE(ABORT,'Assignment startup fence is immutable'); END; +CREATE TRIGGER ticket_assignment_operation_fence_no_delete BEFORE DELETE ON ticket_assignment_operation_fences +BEGIN SELECT RAISE(ABORT,'Assignment startup fence is immutable'); END; +CREATE TRIGGER ticket_assignment_delivery_origin_no_update BEFORE UPDATE ON ticket_assignment_delivery_origins +BEGIN SELECT RAISE(ABORT,'Assignment input origin is immutable'); END; +CREATE TRIGGER ticket_assignment_delivery_origin_no_delete BEFORE DELETE ON ticket_assignment_delivery_origins +BEGIN SELECT RAISE(ABORT,'Assignment input origin is immutable'); END; +"#; + +/// Additive Workspace schema only. Native pre-upgrade assignments use their +/// committed initial audit time to exclude earlier Ticket inputs; ambiguous +/// legacy relationships are NOT treated as fresh delegations. Migration inputs +/// retain the exact Queue fence already reconciled by Ticket schema 11. +pub(super) fn install(conn: &Connection) -> Result<()> { + conn.execute_batch(SCHEMA_SQL)?; + conn.execute_batch(r#" +INSERT INTO ticket_assignment_delivery_origins +SELECT h.workspace_id,h.ticket_id,h.assignment_id,'assignment-event:' || h.event_id, + COALESCE((SELECT MAX(e.event_index) FROM typed_ticket_events e + WHERE e.workspace_id=h.workspace_id AND e.ticket_id=h.ticket_id AND e.ata.after_ticket_event_index + -- Continuing request intent is explicit: fenced assignment answers or item content edits. + -- Ordinary comments and all case/routing/handling history are not new requests. + AND e.kind IN ('assignment_answer','item_edit') + AND (e.kind<>'assignment_answer' OR EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value=a.assignment_id)) + AND e.kind NOT LIKE 'assignment_tracking%' + AND e.kind NOT LIKE 'assignment_delivery%' + AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking') + AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>a.assignment_id) +) +SELECT i.ticket_id,i.assignment_id,i.input_event_id, + json_extract(i.tracking_json,'$.waiting_reason'),json_extract(i.tracking_json,'$.next_action'),d.payload_json +FROM inputs i +LEFT JOIN ticket_assignment_deliveries d ON d.workspace_id=?1 AND d.assignment_id=i.assignment_id AND d.input_event_id=i.input_event_id +WHERE NOT EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements ack + WHERE ack.workspace_id=?1 AND ack.delivery_id=d.delivery_id) +AND (?3 IS NULL OR i.assignment_id=?3) AND (?4 IS NULL OR i.input_event_id=?4) +"#; + +fn decode_payload(payload: &str) -> Result { + serde_json::from_str(payload).map_err(|e| Error::Store(e.to_string())) +} + +pub(super) fn list_in_tx( + conn: &Connection, + workspace_id: &str, + limit: usize, +) -> Result> { + inputs_in_tx(conn, workspace_id, limit, None, None) +} +fn inputs_in_tx( + conn: &Connection, + workspace_id: &str, + limit: usize, + assignment_id: Option<&str>, + input_event_id: Option<&str>, +) -> Result> { + let sql = format!("{INPUTS_SQL} ORDER BY i.ticket_id,i.assignment_id,i.input_order LIMIT ?2"); + let mut statement = conn.prepare(&sql)?; + let rows = statement.query_map( + params![workspace_id, limit as i64, assignment_id, input_event_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + row.get::<_, Option>(5)?, + )) + }, + )?; + rows.map(|row| { + let (ticket_id, assignment_id, input_event_id, waiting_reason, next_action, payload) = row?; + if let Some(payload) = payload { + return decode_payload(&payload); + } + // Hash the identities, not mutable handling. Scope is Workspace-qualified. + let identities = serde_json::to_string(&(workspace_id, &assignment_id, &input_event_id)) + .map_err(|e| Error::Store(e.to_string()))?; + let digest = Sha256::digest(identities.as_bytes()); + let hex = digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let delivery_id = format!("assignment-delivery:{hex}"); + Ok(TicketAssignmentDelivery { + ticket_id, + assignment_id, + input_event_id, + request_id: delivery_id.clone(), + delivery_id, + waiting_reason, + next_action, + }) + }) + .collect() +} + +fn source_guard( + conn: &Connection, + workspace_id: &str, + source: &TicketAssignmentTrackingSourceContext, +) -> Result<()> { + assignment_tracking::require_runtime_source( + conn, + workspace_id, + &source.runtime_id, + &source.worker_id, + &source.agent_key, + &source.authentication_binding_id, + ) +} + +pub(super) fn require_current( + conn: &Connection, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, +) -> Result<()> { + let current:bool=conn.query_row(r#"SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments c + JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id + JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id + WHERE c.workspace_id=?1 AND c.ticket_id=?2 AND c.assignment_id=?3 AND c.role='orchestrator' + AND a.principal_kind='workspace_agent' AND a.principal_id='workspace-orchestrator' + AND t.workflow_state='open' AND json_extract(a.tracking_json,'$.status') IN ('waiting','inprogress') + AND EXISTS(SELECT 1 FROM ticket_assignment_delivery_origins o WHERE o.workspace_id=a.workspace_id AND o.assignment_id=a.assignment_id) + AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id))"#,params![workspace_id,ticket_id,assignment_id],|r|r.get(0))?; + if !current { + return Err(Error::TicketAssignmentConflict( + "Assignment is no longer a current actionable delegation".into(), + )); + } + Ok(()) +} + +pub(super) fn reserve( + store: &SqliteWorkspaceStore, + workspace_id: &str, + assignment_id: &str, + input_event_id: &str, + source: &TicketAssignmentTrackingSourceContext, +) -> Result { + store.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + source_guard(&tx,workspace_id,source)?; + let ticket_id:String=tx.query_row("SELECT ticket_id FROM ticket_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2",params![workspace_id,assignment_id],|r|r.get(0))?; + require_current(&tx,workspace_id,&ticket_id,assignment_id)?; + let saved:Option=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND assignment_id=?2 AND input_event_id=?3",params![workspace_id,assignment_id,input_event_id],|r|r.get(0)).optional()?; + if let Some(saved)=saved {let result=decode_payload(&saved)?;tx.commit()?;return Ok(result);} + // A caller cannot fabricate an input. Restrict the same common query by + // exact assignment/input identity rather than trusting a scan snapshot. + let candidates=inputs_in_tx(&tx,workspace_id,1,Some(assignment_id),Some(input_event_id))?; + let delivery=candidates.into_iter().find(|d|d.assignment_id==assignment_id && d.input_event_id==input_event_id) + .ok_or_else(||Error::TicketAssignmentConflict("Assignment input is not pending".into()))?; + tx.execute("INSERT INTO ticket_assignment_deliveries VALUES(?1,?2,?3,?4,?5,?6)",params![workspace_id,assignment_id,input_event_id,delivery.delivery_id,serde_json::to_string(&delivery).map_err(|e|Error::Store(e.to_string()))?,serde_json::to_string(source).map_err(|e|Error::Store(e.to_string()))?])?; + tx.commit()?; + Ok(delivery) + }) +} + +/// Persist the exact rendered Runtime request before I/O. A losing renderer or +/// retry receives the original bytes, not a new payload under the same ID. +pub(super) fn prepare_content( + store: &SqliteWorkspaceStore, + workspace_id: &str, + delivery_id: &str, + content: &str, + source: &TicketAssignmentTrackingSourceContext, +) -> Result { + if content.is_empty() || content.len() > 262144 || content.contains('\0') { + return Err(Error::InvalidInput( + "Assignment delivery content must be nonempty, NUL-free and at most 262144 bytes" + .into(), + )); + } + store.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + source_guard(&tx,workspace_id,source)?; + let payload:String=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0))?; + let delivery=decode_payload(&payload)?; + require_current(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id)?; + let saved:Option=tx.query_row("SELECT content FROM ticket_assignment_delivery_requests WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?; + let result=if let Some(saved)=saved {saved} else { + tx.execute("INSERT INTO ticket_assignment_delivery_requests VALUES(?1,?2,?3)",params![workspace_id,delivery_id,content])?; + content.to_owned() + }; + tx.commit()?; + Ok(result) + }) +} + +pub(super) fn ack( + store: &SqliteWorkspaceStore, + workspace_id: &str, + delivery_id: &str, + source: &TicketAssignmentTrackingSourceContext, +) -> Result<()> { + store.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + source_guard(&tx,workspace_id,source)?; + let payload:String=tx.query_row("SELECT payload_json FROM ticket_assignment_deliveries WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0))?; + let delivery=decode_payload(&payload)?; + require_current(&tx,workspace_id,&delivery.ticket_id,&delivery.assignment_id)?; + let source_json=serde_json::to_string(source).map_err(|e|Error::Store(e.to_string()))?; + let saved:Option=tx.query_row("SELECT source_json FROM ticket_assignment_delivery_acknowledgements WHERE workspace_id=?1 AND delivery_id=?2",params![workspace_id,delivery_id],|r|r.get(0)).optional()?; + if let Some(saved)=saved { + if saved!=source_json {return Err(Error::TicketAssignmentConflict("Delivery acknowledgement source differs from saved receipt".into()));} + } else {tx.execute("INSERT INTO ticket_assignment_delivery_acknowledgements VALUES(?1,?2,?3)",params![workspace_id,delivery_id,source_json])?;} + tx.commit()?; + Ok(()) + }) +} + +/// Must run before receipt replay as well as every bind/finalize write. Absence +/// is the existing manual reservation contract, not inferred delegation. +pub(super) fn operation_guard( + conn: &Connection, + workspace_id: &str, + operation_id: &str, +) -> Result<()> { + let fence=conn.query_row("SELECT o.ticket_id,f.orchestrator_assignment_id,f.runtime_id,f.worker_id,f.agent_key,f.authentication_binding_id FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND f.operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,String>(5)?))).optional()?; + if let Some((ticket_id, assignment_id, runtime_id, worker_id, agent_key, binding_id)) = fence { + assignment_tracking::require_runtime_source( + conn, + workspace_id, + &runtime_id, + &worker_id, + &agent_key, + &binding_id, + )?; + require_current(conn, workspace_id, &ticket_id, &assignment_id)?; + } + Ok(()) +} + +pub(super) fn save_operation_fence( + conn: &Connection, + workspace_id: &str, + operation_id: &str, + ticket_id: &str, + assignment_id: &str, + source: &TicketAssignmentTrackingSourceContext, + existing: bool, +) -> Result<()> { + source_guard(conn, workspace_id, source)?; + require_current(conn, workspace_id, ticket_id, assignment_id)?; + let source_json = serde_json::to_string(source).map_err(|e| Error::Store(e.to_string()))?; + let saved:Option<(String,String)>=conn.query_row("SELECT orchestrator_assignment_id,source_json FROM ticket_assignment_operation_fences WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()?; + if let Some(saved) = saved { + if saved != (assignment_id.to_owned(), source_json) { + return Err(Error::TicketAssignmentConflict( + "Startup operation assignment/source differs from saved fence".into(), + )); + } + } else { + if existing { + return Err(Error::TicketAssignmentConflict( + "Cannot add a delegation fence to an existing manual operation".into(), + )); + } + conn.execute( + "INSERT INTO ticket_assignment_operation_fences VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", + params![ + workspace_id, + operation_id, + assignment_id, + source.runtime_id, + source.worker_id, + source.agent_key, + source.authentication_binding_id, + source_json + ], + )?; + } + Ok(()) +} + +// Same input selection as delivery, plus immutable delivered history. Never +// SELECT payload/source/content into the public diagnostic projection. +const DIAGNOSTIC_INPUTS_SQL: &str = r#" +WITH inputs AS ( + SELECT o.input_event_id,-1 AS input_order FROM ticket_assignment_delivery_origins o + WHERE o.workspace_id=?1 AND o.assignment_id=?3 + UNION + SELECT d.input_event_id,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END + FROM ticket_assignment_deliveries d WHERE d.workspace_id=?1 AND d.assignment_id=?3 + UNION + SELECT 'ticket-event:' || e.event_index,e.event_index + FROM ticket_assignment_delivery_origins o JOIN typed_ticket_events e ON e.workspace_id=o.workspace_id AND e.ticket_id=o.ticket_id + WHERE o.workspace_id=?1 AND o.ticket_id=?2 AND o.assignment_id=?3 AND e.event_index>o.after_ticket_event_index + AND e.kind IN ('assignment_answer','item_edit') + AND (e.kind<>'assignment_answer' OR EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value=?3)) + AND e.kind NOT LIKE 'assignment_tracking%' AND e.kind NOT LIKE 'assignment_delivery%' + AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking') + -- Retain historical input identity after done/release. A later delegation's + -- origin bounds ordinary events; an explicit answer's attribute is authoritative. + AND e.event_index<=COALESCE((SELECT MIN(later.after_ticket_event_index) FROM ticket_assignment_delivery_origins later + JOIN ticket_worker_assignments la ON la.workspace_id=later.workspace_id AND la.assignment_id=later.assignment_id + JOIN ticket_worker_assignments original ON original.workspace_id=o.workspace_id AND original.assignment_id=o.assignment_id + WHERE later.workspace_id=o.workspace_id AND later.ticket_id=o.ticket_id AND la.rowid>original.rowid),9223372036854775807) + AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>?3) +) +"#; + +pub(super) fn diagnostics_page( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_input_event_id: Option<&str>, + after_start_operation_id: Option<&str>, + limit: usize, +) -> Result { + if !(1..=100).contains(&limit) { + return Err(Error::InvalidInput( + "Diagnostic page limit must be 1..100".into(), + )); + } + store.with_conn(|conn| { + // One read snapshot for current assignment, evidence and cursor validation. + let tx = conn.unchecked_transaction()?; + let assignment = ticket::sqlite_ticket_assignment_tracking(&tx,workspace_id,ticket_id,assignment_id) + .map_err(|e| Error::Store(e.to_string()))?.ok_or_else(|| Error::InvalidInput("Assignment does not belong to this Ticket".into()))?; + let known: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_assignment_delivery_origins WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3)",params![workspace_id,ticket_id,assignment_id],|r| r.get(0))?; + let actionable = match require_current(&tx,workspace_id,ticket_id,assignment_id) { + Ok(()) => true, + Err(Error::TicketAssignmentConflict(_)) => false, + Err(error) => return Err(error), + }; + let after = match after_input_event_id { + None => -2, + Some(id) => tx.query_row(&format!("{DIAGNOSTIC_INPUTS_SQL} SELECT input_order FROM inputs WHERE input_event_id=?5"),params![workspace_id,ticket_id,assignment_id,actionable,id],|r| r.get::<_,i64>(0)).optional()?.ok_or_else(|| Error::InvalidInput("Input cursor does not belong to this assignment".into()))?, + }; + let sql = format!("{DIAGNOSTIC_INPUTS_SQL} SELECT i.input_event_id,d.delivery_id,EXISTS(SELECT 1 FROM ticket_assignment_delivery_requests req WHERE req.workspace_id=?1 AND req.delivery_id=d.delivery_id),EXISTS(SELECT 1 FROM ticket_assignment_delivery_acknowledgements ack WHERE ack.workspace_id=?1 AND ack.delivery_id=d.delivery_id) FROM inputs i LEFT JOIN ticket_assignment_deliveries d ON d.workspace_id=?1 AND d.assignment_id=?3 AND d.input_event_id=i.input_event_id WHERE i.input_order>?5 ORDER BY i.input_order LIMIT ?6"); + let mut stmt = tx.prepare(&sql)?; + let mut inputs = stmt.query_map(params![workspace_id,ticket_id,assignment_id,actionable,after,(limit+1) as i64],|r| { + let delivery_id: Option = r.get(1)?; + let prepared: bool = r.get(2)?; + let ack: bool = r.get(3)?; + use server_api::TicketAssignmentInputDeliveryState::*; + let notification = if ack { RuntimeAccepted } else if prepared { RequestPersisted } else if delivery_id.is_some() { Reserved } else { NotReserved }; + Ok(server_api::TicketAssignmentInputDiagnostic { input_event_id:r.get(0)?, delivery_id,notification,acceptance_outcome_unknown:prepared && !ack }) + })?.collect::,_>>()?; + let more = inputs.len()>limit; + inputs.truncate(limit); + let next_input_event_id = if more { inputs.last().map(|i| i.input_event_id.clone()) } else { None }; + let after_start = match after_start_operation_id { + None => 0, + Some(id) => tx.query_row("SELECT f.rowid FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND f.orchestrator_assignment_id=?3 AND o.ticket_id=?2 AND f.operation_id=?4",params![workspace_id,ticket_id,assignment_id,id],|r| r.get::<_,i64>(0)).optional()?.ok_or_else(|| Error::InvalidInput("Start cursor does not belong to this assignment".into()))?, + }; + let mut stmt = tx.prepare("SELECT f.operation_id,o.claim_state,o.assignment_id,o.runtime_id,o.worker_id FROM ticket_assignment_operation_fences f JOIN ticket_assignment_operations o ON o.workspace_id=f.workspace_id AND o.operation_id=f.operation_id WHERE f.workspace_id=?1 AND o.ticket_id=?2 AND f.orchestrator_assignment_id=?3 AND f.rowid>?4 ORDER BY f.rowid LIMIT ?5")?; + let mut starts = stmt.query_map(params![workspace_id,ticket_id,assignment_id,after_start,(limit+1) as i64],|r| Ok(server_api::TicketAssignmentStartDiagnostic { operation_id:r.get(0)?,claim_state:r.get(1)?,assignment_id:r.get(2)?,runtime_id:r.get(3)?,worker_id:r.get(4)?,actual_processing_unknown:true }))?.collect::,_>>()?; + let more = starts.len()>limit; + starts.truncate(limit); + let next_start_operation_id = if more { starts.last().map(|i| i.operation_id.clone()) } else { None }; + Ok(server_api::TicketAssignmentDiagnosticsPage { assignment_id:assignment_id.into(),current:assignment.current,input_origin_known:known,actionable,inputs,next_input_event_id,starts,next_start_operation_id }) + }) +} diff --git a/crates/workspace-server/src/assignment_delivery_tests.rs b/crates/workspace-server/src/assignment_delivery_tests.rs new file mode 100644 index 000000000..c8e2cb7f6 --- /dev/null +++ b/crates/workspace-server/src/assignment_delivery_tests.rs @@ -0,0 +1,1035 @@ +// Included in store::tests: exercise persisted transaction boundaries, not Runtime I/O. +fn delivery_store_external_event( + store: &SqliteWorkspaceStore, + index: i64, + kind: &str, + state_field: Option<&str>, +) { + store.with_conn(|conn| { + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,state_field,body) VALUES('w','tracking',?1,?2,'user','2026-10-12T00:00:00Z',?3,'Answer arrived')",params![index,kind,state_field])?; + Ok(()) + }).unwrap(); +} + +fn delivery_store_answer(store: &SqliteWorkspaceStore, key: &str) { + let current = store + .list_current_ticket_assignment_tracking("w", "tracking") + .unwrap() + .remove(0); + store + .answer_ticket_assignment( + "w", + "tracking", + &ticket::TicketAssignmentAnswer { + assignment_id: current.assignment_id, + expected_last_event_id: current.last_event_id.unwrap(), + operation_key: key.into(), + body: "Explicit continuing answer".into(), + }, + &assignment_answer_source(), + "fixture-actor", + ) + .unwrap(); +} + +fn delivery_store_reserve_initial( + store: &SqliteWorkspaceStore, + source: &TicketAssignmentTrackingSourceContext, +) -> TicketAssignmentDelivery { + let inputs = store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap(); + assert_eq!(inputs.len(), 1); + store + .reserve_ticket_assignment_delivery( + "w", + &inputs[0].assignment_id, + &inputs[0].input_event_id, + source, + ) + .unwrap() +} + +#[tokio::test] +async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_are_pending() { + let (_directory, store) = tracking_store_fixture().await; + // Existing history before Assign must not become a new consideration input. + delivery_store_external_event(&store, 0, "comment", None); + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let initial = delivery_store_reserve_initial(&store, &source); + store + .ack_ticket_assignment_delivery("w", &initial.delivery_id, &source) + .unwrap(); + let mut update = tracking_store_input("a", "event-assign-a", "waiting-a"); + update.tracking.status = TicketAssignmentTrackingStatus::Waiting; + update.tracking.waiting_reason = Some("Waiting for owner answer".into()); + update.tracking.next_action = Some("Read answer and decide".into()); + store + .update_ticket_assignment_tracking("w", "tracking", &update, &source) + .unwrap(); + assert!( + store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap() + .is_empty() + ); + delivery_store_external_event( + &store, + 1, + "assignment_tracking", + Some("assignment_tracking"), + ); + delivery_store_external_event(&store, 2, "comment", None); + assert!( + store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap() + .is_empty() + ); + delivery_store_answer(&store, "answer-after-comment"); + let pending = store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].input_event_id, "ticket-event:3"); + assert_eq!( + pending[0].waiting_reason.as_deref(), + Some("Waiting for owner answer") + ); + assert_eq!( + pending[0].next_action.as_deref(), + Some("Read answer and decide") + ); + let answer = store + .reserve_ticket_assignment_delivery("w", "a", "ticket-event:3", &source) + .unwrap(); + store + .ack_ticket_assignment_delivery("w", &answer.delivery_id, &source) + .unwrap(); + assert!( + store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap() + .is_empty() + ); + // Explicit fenced answers remain inputs; own tracking/history and ordinary + // comments remain excluded. + delivery_store_external_event(&store, 4, "assignment_tracking_updated", None); + delivery_store_external_event(&store, 5, "history", None); + delivery_store_answer(&store, "next-explicit-answer"); + let pending = store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].input_event_id, "ticket-event:6"); + let saved = store + .get_ticket_assignment_tracking("w", "tracking", "a") + .unwrap() + .unwrap(); + assert_eq!( + saved.tracking.unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); +} + +#[tokio::test] +async fn assignment_delivery_response_loss_returns_immutable_payload_after_progress_changes_and_reopen() + { + let (directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let initial = delivery_store_reserve_initial(&store, &source); + let mut update = tracking_store_input("a", "event-assign-a", "waiting-a"); + update.tracking.status = TicketAssignmentTrackingStatus::Waiting; + update.tracking.waiting_reason = Some("New reason not part of original delivery".into()); + store + .update_ticket_assignment_tracking("w", "tracking", &update, &source) + .unwrap(); + drop(store); + let store = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); + assert_eq!( + store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap(), + vec![initial.clone()] + ); + assert_eq!( + store + .reserve_ticket_assignment_delivery("w", "a", &initial.input_event_id, &source) + .unwrap(), + initial + ); + store + .ack_ticket_assignment_delivery("w", &initial.delivery_id, &source) + .unwrap(); + store + .ack_ticket_assignment_delivery("w", &initial.delivery_id, &source) + .unwrap(); + assert!( + store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap() + .is_empty() + ); + store + .with_conn(|conn| { + assert!( + conn.execute( + "UPDATE ticket_assignment_deliveries SET payload_json='{}'", + [] + ) + .is_err() + ); + assert!( + conn.execute( + "DELETE FROM ticket_assignment_delivery_acknowledgements", + [] + ) + .is_err() + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_delivery_simultaneous_reservations_converge_to_one_receipt() { + let (directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let pending = store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap() + .remove(0); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let threads = (0..2) + .map(|_| { + let barrier = barrier.clone(); + let path = directory.path().join("workspace.db"); + let input = pending.input_event_id.clone(); + std::thread::spawn(move || { + let store = SqliteWorkspaceStore::open_for_test(path).unwrap(); + barrier.wait(); + store + .reserve_ticket_assignment_delivery("w", "a", &input, &tracking_store_source()) + .unwrap() + }) + }) + .collect::>(); + let receipts = threads + .into_iter() + .map(|t| t.join().unwrap()) + .collect::>(); + assert_eq!(receipts[0], receipts[1]); + store + .with_conn(|conn| { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_deliveries", + [], + |r| r.get(0), + )?; + assert_eq!(count, 1); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_delivery_done_close_reopen_and_unknown_history_do_not_revive_delegation() { + for change in ["done", "close-reopen", "unknown"] { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let initial = delivery_store_reserve_initial(&store, &source); + match change { + "done" => { + store + .update_ticket_assignment_tracking( + "w", + "tracking", + &tracking_store_input("a", "event-assign-a", "done-a"), + &source, + ) + .unwrap(); + } + "close-reopen" => { + store.with_conn(|conn| {conn.execute("UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id='tracking'",[])?;conn.execute("UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id='tracking'",[])?;Ok(())}).unwrap(); + } + _ => { + store.with_conn(|conn| {conn.execute("UPDATE ticket_worker_assignments SET tracking_json=NULL,last_tracking_event_id=NULL WHERE assignment_id='a'",[])?;Ok(())}).unwrap(); + } + } + delivery_store_external_event(&store, 1, "comment", None); + assert!( + store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap() + .is_empty(), + "{change}" + ); + assert!( + store + .reserve_ticket_assignment_delivery("w", "a", &initial.input_event_id, &source) + .is_err(), + "{change}" + ); + } +} + +#[tokio::test] +async fn assignment_delivery_clear_reassign_fences_scanned_and_reserved_inputs() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let old = delivery_store_reserve_initial(&store, &source); + store + .clear_current_ticket_role_assignment( + "w", + "tracking", + TicketAssignmentRole::Orchestrator, + "a", + "clear-a", + "clear-a", + "owner", + "2", + None, + ) + .unwrap(); + let new = tracking_store_assign(&store, "b", "assign-b"); + assert_eq!(new.assignment_id, "b"); + assert!( + store + .reserve_ticket_assignment_delivery("w", "a", &old.input_event_id, &source) + .is_err() + ); + assert!( + store + .ack_ticket_assignment_delivery("w", &old.delivery_id, &source) + .is_err() + ); + let pending = store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap(); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].assignment_id, "b"); + assert_ne!(pending[0].request_id, old.request_id); + store + .with_conn(|conn| { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignments WHERE role='orchestrator'", + [], + |r| r.get(0), + )?; + assert_eq!(count, 2); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_delivery_old_worker_or_authenticated_binding_cannot_reserve_or_ack() { + for change in ["replacement", "revoke", "binding-change"] { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let old = delivery_store_reserve_initial(&store, &source); + store.with_conn(|conn| { + match change { + "replacement" => {conn.execute("UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'",[])?;}, + "revoke" => {conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2' WHERE workspace_id='w'",[])?;}, + _ => {conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-binding' WHERE workspace_id='w'",[])?;}, + } Ok(()) + }).unwrap(); + assert!( + matches!( + store.reserve_ticket_assignment_delivery("w", "a", &old.input_event_id, &source), + Err(Error::WorkspacePermissionDenied(_)) + ), + "{change}" + ); + assert!( + matches!( + store.ack_ticket_assignment_delivery("w", &old.delivery_id, &source), + Err(Error::WorkspacePermissionDenied(_)) + ), + "{change}" + ); + if change == "replacement" { + let mut replacement = source.clone(); + replacement.worker_id = "replacement".into(); + assert_eq!( + store + .reserve_ticket_assignment_delivery("w", "a", &old.input_event_id, &replacement) + .unwrap(), + old + ); + store + .ack_ticket_assignment_delivery("w", &old.delivery_id, &replacement) + .unwrap(); + assert_eq!( + store + .list_current_ticket_assignment_tracking("w", "tracking") + .unwrap() + .len(), + 1 + ); + } + } +} + +fn delivery_store_start_reserve( + store: &SqliteWorkspaceStore, + key: &str, + assignment: &str, + source: &TicketAssignmentTrackingSourceContext, +) -> Result { + store.reserve_ticket_assignment_operation_for_orchestrator( + "w", + key, + "tracking", + "r", + Some("replacement"), + "start-input", + "3", + assignment, + source, + ) +} + +#[tokio::test] +async fn assignment_startup_clear_reassign_fences_reservation_replay_bind_and_finalize() { + for change in ["clear", "reassign", "close-reopen"] { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + assert!(delivery_store_start_reserve(&store, "start-a", "a", &source).unwrap()); + assert!(!delivery_store_start_reserve(&store, "start-a", "a", &source).unwrap()); + if change == "close-reopen" { + store.with_conn(|conn| {conn.execute("UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id='tracking'",[])?;conn.execute("UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id='tracking'",[])?;Ok(())}).unwrap(); + } else { + // Clear/reassign must not be blocked by a pending startup claim: + // assignment authority can invalidate it without Runtime side effects. + store + .clear_current_ticket_role_assignment( + "w", + "tracking", + TicketAssignmentRole::Orchestrator, + "a", + "clear-a", + "clear-a", + "owner", + "4", + None, + ) + .unwrap(); + if change == "reassign" { + tracking_store_assign(&store, "b", "assign-b"); + } + } + assert!( + delivery_store_start_reserve(&store, "start-a", "a", &source).is_err(), + "{change}" + ); + assert!( + delivery_store_start_reserve(&store, "start-new", "a", &source).is_err(), + "{change}" + ); + assert!( + store + .validate_ticket_assignment_operation_fence("w", "start-a") + .is_err(), + "{change}" + ); + assert!( + store + .bind_ticket_assignment_operation_worker("w", "start-a", "replacement") + .is_err(), + "{change}" + ); + let record = TicketWorkerAssignmentRecord { + workspace_id: "w".into(), + ticket_id: "tracking".into(), + assignment_id: "target-a".into(), + worker: RuntimeWorkerRef::new("r", "replacement"), + assigned_by: "owner".into(), + assigned_at: "4".into(), + }; + assert!( + store + .set_current_ticket_worker_assignment( + &record, + None, + "worker-event", + "start-a", + false + ) + .is_err(), + "{change}" + ); + assert!( + store + .get_active_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Worker) + .unwrap() + .is_none() + ); + } +} + +#[tokio::test] +async fn assignment_startup_replaced_worker_cannot_start_or_replay_saved_operations() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + delivery_store_start_reserve(&store, "start-a", "a", &source).unwrap(); + store + .with_conn(|conn| { + conn.execute( + "UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'", + [], + )?; + Ok(()) + }) + .unwrap(); + assert!(matches!( + delivery_store_start_reserve(&store, "start-a", "a", &source), + Err(Error::WorkspacePermissionDenied(_)) + )); + assert!(matches!( + delivery_store_start_reserve(&store, "start-new", "a", &source), + Err(Error::WorkspacePermissionDenied(_)) + )); + assert!(matches!( + store.validate_ticket_assignment_operation_fence("w", "start-a"), + Err(Error::WorkspacePermissionDenied(_)) + )); + assert!(matches!( + store.bind_ticket_assignment_operation_worker("w", "start-a", "replacement"), + Err(Error::WorkspacePermissionDenied(_)) + )); + // Even the new Worker cannot adopt the old sender's startup operation key. + let mut replacement = source.clone(); + replacement.worker_id = "replacement".into(); + assert!(delivery_store_start_reserve(&store, "start-a", "a", &replacement).is_err()); +} + +#[tokio::test] +async fn assignment_startup_saved_fence_cannot_be_bypassed_or_attached_to_manual_receipt() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + delivery_store_start_reserve(&store, "start-a", "a", &source).unwrap(); + assert!( + store + .reserve_ticket_assignment_operation( + "w", + "start-a", + "tracking", + "r", + Some("replacement"), + "start-input", + "3" + ) + .is_err() + ); + store.with_conn(|conn| {assert!(conn.execute("UPDATE ticket_assignment_operation_fences SET orchestrator_assignment_id='different'",[]).is_err());Ok(())}).unwrap(); + store + .fail_ticket_assignment_operation("w", "start-a", "explicit cancellation") + .unwrap(); + store + .reserve_ticket_assignment_operation( + "w", + "manual", + "tracking", + "r", + Some("replacement"), + "start-input", + "3", + ) + .unwrap(); + assert!(delivery_store_start_reserve(&store, "manual", "a", &source).is_err()); +} + +#[tokio::test] +async fn assignment_delivery_rendered_content_is_immutable_across_restart_and_concurrent_renderers() +{ + let (directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let initial = delivery_store_reserve_initial(&store, &source); + assert_eq!( + store + .prepare_ticket_assignment_delivery_content( + "w", + &initial.delivery_id, + "original rendered prompt", + &source + ) + .unwrap(), + "original rendered prompt" + ); + drop(store); + let store = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); + assert_eq!( + store + .prepare_ticket_assignment_delivery_content( + "w", + &initial.delivery_id, + "different title/handling/config prompt", + &source + ) + .unwrap(), + "original rendered prompt" + ); + store + .with_conn(|conn| { + assert!( + conn.execute( + "UPDATE ticket_assignment_delivery_requests SET content='changed'", + [] + ) + .is_err() + ); + assert!( + conn.execute("DELETE FROM ticket_assignment_delivery_requests", []) + .is_err() + ); + Ok(()) + }) + .unwrap(); + store + .clear_current_ticket_role_assignment( + "w", + "tracking", + TicketAssignmentRole::Orchestrator, + "a", + "clear-a", + "clear-a", + "owner", + "4", + None, + ) + .unwrap(); + assert!( + store + .prepare_ticket_assignment_delivery_content( + "w", + &initial.delivery_id, + "original rendered prompt", + &source + ) + .is_err() + ); +} + +#[test] +fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_legacy_history() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("schema92.db"); + // A genuine retained Ticket-10/Workspace-91 fixture goes through its actual + // applied tracking migration; no current DB markers or columns are lowered. + let conn = tracking_schema91_fixture(&path); + migrate_ticket_tracking_v91_to_v92(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 92); + let assignments = stored_fixture_rows(&conn, "ticket_worker_assignments"); + let operations = stored_fixture_rows(&conn, "ticket_assignment_operations"); + let tracking = stored_fixture_rows(&conn, "typed_ticket_assignment_tracking_history"); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let pending = store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap(); + assert_eq!( + pending + .iter() + .map(|d| d.ticket_id.as_str()) + .collect::>(), + vec!["pending", "progress"] + ); + assert_eq!( + pending[0].input_event_id, + "assignment-event:migration-tracking:a-pending" + ); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 93); + assert_eq!( + stored_fixture_rows(conn, "ticket_worker_assignments"), + assignments + ); + assert_eq!( + stored_fixture_rows(conn, "ticket_assignment_operations"), + operations + ); + assert_eq!( + stored_fixture_rows(conn, "typed_ticket_assignment_tracking_history"), + tracking + ); + let violations: i64 = + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| { + r.get(0) + })?; + assert_eq!(violations, 0); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_startup_simultaneous_reservations_admit_runtime_effects_once() { + let (directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let barrier = std::sync::Arc::new(std::sync::Barrier::new(2)); + let threads = (0..2) + .map(|_| { + let barrier = barrier.clone(); + let path = directory.path().join("workspace.db"); + std::thread::spawn(move || { + let store = SqliteWorkspaceStore::open_for_test(path).unwrap(); + barrier.wait(); + delivery_store_start_reserve(&store, "start-a", "a", &tracking_store_source()) + .unwrap() + }) + }) + .collect::>(); + let admitted = threads + .into_iter() + .map(|t| t.join().unwrap()) + .collect::>(); + assert_eq!(admitted.iter().filter(|result| **result).count(), 1); + store + .with_conn(|conn| { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_operation_fences", + [], + |r| r.get(0), + )?; + assert_eq!(count, 1); + Ok(()) + }) + .unwrap(); +} + +#[test] +fn assignment_delivery_fresh_and_upgraded_operational_schemas_are_identical() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("retained.db"); + let conn = tracking_schema91_fixture(&path); + migrate_ticket_tracking_v91_to_v92(&conn).unwrap(); + drop(conn); + let upgraded = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap(); + let objects = |store: &SqliteWorkspaceStore| { + store.with_conn(|conn| { + let mut statement=conn.prepare("SELECT type,name,sql FROM sqlite_schema WHERE name LIKE 'ticket_assignment_delivery_%' OR name LIKE 'ticket_assignment_operation_fence%' ORDER BY name")?; + Ok(statement.query_map([],|r|Ok((r.get::<_,String>(0)?,r.get::<_,String>(1)?,r.get::<_,String>(2)?)))?.collect::>>()?) + }).unwrap() + }; + assert_eq!(objects(&fresh), objects(&upgraded)); +} + +#[tokio::test] +async fn assignment_delivery_missing_immutable_request_guard_refuses_startup() { + let (directory, store) = tracking_store_fixture().await; + store + .with_conn(|conn| { + conn.execute( + "DROP TRIGGER ticket_assignment_delivery_request_no_update", + [], + )?; + Ok(()) + }) + .unwrap(); + drop(store); + assert!( + matches!(SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")),Err(Error::Store(message)) if message.contains("ticket_assignment_delivery_request_no_update")) + ); +} + +#[tokio::test] +async fn assignment_delivery_fabricated_inputs_and_foreign_workspace_cannot_create_receipts() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + assert!( + store + .reserve_ticket_assignment_delivery("w", "a", "ticket-event:9999", &source) + .is_err() + ); + assert!( + store + .reserve_ticket_assignment_delivery( + "other", + "a", + "assignment-event:event-assign-a", + &source + ) + .is_err() + ); + store + .with_conn(|conn| { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_deliveries", + [], + |r| r.get(0), + )?; + assert_eq!(count, 0); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_startup_unfinished_dependencies_remain_diagnostics_not_store_admission_gates() { + let (_directory, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "partial", "open"); + seed_manual_start_ticket(&store, "dependency", "open"); + store.with_conn(|conn| { + conn.execute("INSERT INTO typed_ticket_relations(workspace_id,ticket_id,kind,target,author,at) VALUES('w','partial','depends_on','dependency','owner','1')",[])?; + Ok(()) + }).unwrap(); + let assignment = manual_worker_assignment("partial", "partial-assignment"); + store + .start_ticket_with_worker_assignment( + &assignment, + "partial-event", + "partial-operation", + None, + ) + .unwrap(); + assert!( + store + .get_active_ticket_role_assignment("w", "partial", TicketAssignmentRole::Worker) + .unwrap() + .is_some() + ); + assert!( + store + .get_active_ticket_role_assignment("w", "dependency", TicketAssignmentRole::Worker) + .unwrap() + .is_none() + ); + store + .with_conn(|conn| { + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM typed_ticket_relations WHERE kind='depends_on'", + [], + |r| r.get(0), + )?; + assert_eq!(count, 1); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_diagnostics_keep_persisted_notification_acceptance_and_history_distinct_without_payload() + { + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let initial = store + .list_pending_ticket_assignment_deliveries("w", 10) + .unwrap() + .remove(0); + let page = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap(); + assert!(page.current && page.actionable && page.input_origin_known); + assert_eq!( + page.inputs[0].notification, + server_api::TicketAssignmentInputDeliveryState::NotReserved + ); + let initial = store + .reserve_ticket_assignment_delivery( + "w", + "a", + &initial.input_event_id, + &tracking_store_source(), + ) + .unwrap(); + assert_eq!( + store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap() + .inputs[0] + .notification, + server_api::TicketAssignmentInputDeliveryState::Reserved + ); + store + .prepare_ticket_assignment_delivery_content( + "w", + &initial.delivery_id, + "SECRET RENDERED PAYLOAD", + &tracking_store_source(), + ) + .unwrap(); + let pending = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap(); + assert_eq!( + pending.inputs[0].notification, + server_api::TicketAssignmentInputDeliveryState::RequestPersisted + ); + assert!(pending.inputs[0].acceptance_outcome_unknown); + store + .ack_ticket_assignment_delivery("w", &initial.delivery_id, &tracking_store_source()) + .unwrap(); + delivery_store_answer(&store, "diagnostic-answer"); + delivery_store_external_event(&store, 1, "history", None); + let accepted = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap(); + assert_eq!( + accepted.inputs[0].notification, + server_api::TicketAssignmentInputDeliveryState::RuntimeAccepted + ); + assert!(!accepted.inputs[0].acceptance_outcome_unknown); + assert_eq!( + accepted.next_input_event_id.as_deref(), + Some(initial.input_event_id.as_str()) + ); + let next = store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + accepted.next_input_event_id.as_deref(), + None, + 1, + ) + .unwrap(); + assert_eq!(next.inputs[0].input_event_id, "ticket-event:0"); + assert!(next.next_input_event_id.is_none()); + let wire = serde_json::to_string(&accepted).unwrap(); + for forbidden in [ + "SECRET", + "tracking-binding", + "authenticated-worker", + "payload_json", + "source_json", + "content", + ] { + assert!(!wire.contains(forbidden), "{wire}"); + } + store + .update_ticket_assignment_tracking( + "w", + "tracking", + &tracking_store_input("a", "event-assign-a", "done"), + &tracking_store_source(), + ) + .unwrap(); + let history = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 100) + .unwrap(); + assert!(history.current && !history.actionable); + assert_eq!(history.inputs.len(), 2); // retained inputs are history, not fresh work + assert_eq!( + store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + accepted.next_input_event_id.as_deref(), + None, + 100 + ) + .unwrap() + .inputs[0] + .input_event_id, + "ticket-event:0" + ); + assert!( + store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + Some("ticket-event:999"), + None, + 1 + ) + .is_err() + ); + assert!( + store + .page_ticket_assignment_diagnostics("other", "tracking", "a", None, None, 1) + .is_err() + ); +} + +#[tokio::test] +async fn assignment_diagnostics_unknown_retained_relationship_does_not_invent_input_or_acceptance() +{ + let (_dir, store) = tracking_store_fixture().await; + store.with_conn(|conn| { + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','tracking','legacy-unknown','orchestrator','workspace_agent','workspace-orchestrator','legacy','past')",[])?; + conn.execute("UPDATE ticket_worker_assignments SET tracking_json=NULL,tracking_updated_at=NULL,tracking_migration_diagnostic='Unknown historical relationship' WHERE assignment_id='legacy-unknown'",[])?; + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','tracking','legacy-unknown','orchestrator','workspace_agent','workspace-orchestrator','past')",[])?; + Ok(()) + }).unwrap(); + delivery_store_external_event(&store, 0, "comment", None); + let page = store + .page_ticket_assignment_diagnostics("w", "tracking", "legacy-unknown", None, None, 100) + .unwrap(); + assert!(page.current); + assert!(!page.actionable && !page.input_origin_known); + assert!(page.inputs.is_empty() && page.starts.is_empty()); + assert!( + store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn assignment_diagnostic_start_cursor_pages_existing_claims_without_input_causality_or_processing_inference() + { + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + delivery_store_start_reserve(&store, "z-first-start", "a", &source).unwrap(); + store + .fail_ticket_assignment_operation("w", "z-first-start", "External resource refused start") + .unwrap(); + delivery_store_start_reserve(&store, "a-second-start", "a", &source).unwrap(); + let first = store + .page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 1) + .unwrap(); + assert_eq!(first.starts.len(), 1); + assert_eq!(first.starts[0].operation_id, "z-first-start"); + assert_eq!(first.starts[0].claim_state, "failed"); + assert!(first.starts[0].actual_processing_unknown); + assert_eq!( + first.next_start_operation_id.as_deref(), + Some("z-first-start") + ); + let second = store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + None, + first.next_start_operation_id.as_deref(), + 1, + ) + .unwrap(); + assert_eq!(second.starts[0].operation_id, "a-second-start"); + assert_eq!(second.starts[0].claim_state, "pending"); + assert!(second.starts[0].actual_processing_unknown); + assert!(second.next_start_operation_id.is_none()); + assert!( + store + .page_ticket_assignment_diagnostics( + "w", + "tracking", + "a", + None, + Some("foreign-operation"), + 1 + ) + .is_err() + ); + let wire = serde_json::to_string(&first).unwrap(); + assert!(!wire.contains("tracking-binding") && !wire.contains("External resource")); +} diff --git a/crates/workspace-server/src/assignment_tracking.rs b/crates/workspace-server/src/assignment_tracking.rs new file mode 100644 index 000000000..bf604ca9b --- /dev/null +++ b/crates/workspace-server/src/assignment_tracking.rs @@ -0,0 +1,233 @@ +//! Workspace adapter: Ticket owns assignment identities, current pointers, handling, +//! audit and receipts, including tables with historical Worker-assignment names. +//! Assign/reissue creates a new identity; self-report changes that row using observed +//! event CAS and a complete payload. Exact retry returns a receipt, not a fresh observation. +//! +//! Backend constructs actor/source authority, never from model roles or readable audit +//! text. The whole IMMEDIATE mutation fences the singleton and the binding actually +//! used for authentication, including receipt replay; Worker replacement invalidates +//! the old sender. Account authoring keeps authenticated Account/User and Workspace +//! permissions without requiring a Runtime. Async callers offload the entire helper. +//! +//! Case judgments, handling, delivery/start evidence, Worker lifecycle and grants are +//! separate. Close/reopen does not change handling; the current Orchestrator can report +//! results after close without an active-work grant. Done releases no work or resources. +//! A continuing answer fences current assignment/event and saves input/receipt together; +//! it neither changes handling nor creates an assignment. Done needs explicit reissue. +//! +//! Reads and cursors stay bounded and Workspace/Ticket/assignment scoped. Diagnostics +//! expose neither rendered requests nor binding credentials and attest no model processing. +//! Legacy unknown handling remains unknown: a retained Assign event can be the first +//! report's CAS baseline, not invented waiting or a handling-history cursor. +use super::*; + +/// Backend-authenticated attribution. Readable assigned_by/actor is audit +/// payload, not this authority context. Never decoded from model/API input. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum TicketOrchestratorAssignmentSourceContext { + Runtime { + runtime_id: String, + worker_id: String, + agent_key: String, + authentication_binding_id: String, + }, + Account { + account_id: String, + user_id: String, + }, +} + +pub(super) fn require_runtime_source( + conn: &Connection, + workspace_id: &str, + runtime_id: &str, + worker_id: &str, + agent_key: &str, + binding_id: &str, +) -> Result<()> { + let current_source:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM worker_singleton_owners singleton JOIN workspace_runtime_bindings binding ON binding.workspace_id=singleton.workspace_id AND binding.runtime_id=singleton.runtime_id WHERE singleton.workspace_id=?1 AND singleton.singleton_key=?2 AND singleton.runtime_id=?3 AND singleton.worker_id=?4 AND binding.binding_id=?5 AND binding.revoked_at IS NULL)",params![workspace_id,crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY,runtime_id,worker_id,binding_id],|row|row.get(0))?; + if agent_key != "workspace-orchestrator" || binding_id.trim().is_empty() || !current_source { + return Err(Error::WorkspacePermissionDenied("Assignment source is not the current registered Orchestrator with its authenticated Runtime binding".into())); + } + Ok(()) +} + +pub(super) fn require_assignment_source( + conn: &Connection, + workspace_id: &str, + source: &TicketOrchestratorAssignmentSourceContext, +) -> Result<()> { + match source { + TicketOrchestratorAssignmentSourceContext::Runtime { + runtime_id, + worker_id, + agent_key, + authentication_binding_id, + } => require_runtime_source( + conn, + workspace_id, + runtime_id, + worker_id, + agent_key, + authentication_binding_id, + ), + TicketOrchestratorAssignmentSourceContext::Account { + account_id, + user_id, + } => { + // Backend already authenticates Account/Workspace permission. Do not + // infer a model role or require a Runtime for account authoring. + let exists: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM accounts WHERE account_id=?1)", + [account_id], + |row| row.get(0), + )?; + if !exists || user_id.trim().is_empty() { + return Err(Error::WorkspacePermissionDenied( + "Assignment account source is not a valid Backend actor".into(), + )); + } + Ok(()) + } + } +} + +pub(super) fn update( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, +) -> Result { + store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + // Every call, including a receipt replay, fences the authenticated + // Runtime binding and singleton snapshot under the write lock. Assignment + // replacement/removal may be replayed, Worker replacement may not. + require_runtime_source( + &tx, + workspace_id, + &source.runtime_id, + &source.worker_id, + &source.agent_key, + &source.authentication_binding_id, + )?; + let result = ticket::sqlite_update_ticket_assignment_tracking_in_transaction( + &tx, + workspace_id, + ticket_id, + update, + source, + ) + .map_err(|error| Error::TicketAssignmentConflict(error.to_string()))?; + tx.commit()?; + Ok(result) + }) +} + +pub(super) fn get( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, +) -> Result> { + store.with_conn(|conn| { + ticket::sqlite_ticket_assignment_tracking(conn, workspace_id, ticket_id, assignment_id) + .map_err(|error| Error::Store(error.to_string())) + }) +} + +pub(super) fn current( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, +) -> Result> { + store.with_conn(|conn| { + ticket::sqlite_current_ticket_assignment_tracking(conn, workspace_id, ticket_id) + .map_err(|error| Error::Store(error.to_string())) + }) +} + +pub(super) fn history( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, +) -> Result> { + store.with_conn(|conn| { + ticket::sqlite_ticket_assignment_tracking_history( + conn, + workspace_id, + ticket_id, + assignment_id, + ) + .map_err(|error| Error::Store(error.to_string())) + }) +} + +pub(super) fn history_page( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_event_id: Option<&str>, + limit: usize, +) -> Result { + store.with_conn(|conn| { + ticket::sqlite_ticket_assignment_tracking_history_page( + conn, + workspace_id, + ticket_id, + assignment_id, + after_event_id, + limit, + ) + .map_err(|error| match error { + ticket::TicketError::Conflict(message) => Error::InvalidInput(message), + other => Error::Store(other.to_string()), + }) + }) +} + +pub(super) fn assignments_page( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, +) -> Result { + store.with_conn(|conn| { + let tx = conn.unchecked_transaction()?; + ticket::sqlite_ticket_assignments_page( + &tx, + workspace_id, + ticket_id, + after_assignment_id, + limit, + ) + .map_err(|e| Error::InvalidInput(e.to_string())) + }) +} + +pub(super) fn answer( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + input: &ticket::TicketAssignmentAnswer, + source: &TicketOrchestratorAssignmentSourceContext, + actor: &str, +) -> Result { + store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + require_assignment_source(&tx,workspace_id,source)?; + // Replay retains its saved result after assignment replacement; new input + // must still be admitted by the exact current operational delegation. + let replay: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key=?2)",params![workspace_id,input.operation_key],|r| r.get(0))?; + if !replay { assignment_delivery::require_current(&tx,workspace_id,ticket_id,&input.assignment_id)?; } + let source = serde_json::to_value(source).map_err(|e| Error::Store(e.to_string()))?; + let result = ticket::sqlite_answer_ticket_assignment_in_transaction(&tx,workspace_id,ticket_id,input,&source,actor).map_err(|e| Error::TicketAssignmentConflict(e.to_string()))?; + tx.commit()?; + Ok(result) + }) +} diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs new file mode 100644 index 000000000..5255267c7 --- /dev/null +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -0,0 +1,1824 @@ +// Included in store::tests to reuse the existing hermetic Workspace fixtures. +fn tracking_store_source() -> TicketAssignmentTrackingSourceContext { + TicketAssignmentTrackingSourceContext { + actor: "authenticated-worker:r/worker".into(), + runtime_id: "r".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "tracking-binding".into(), + } +} + +async fn tracking_store_fixture() -> (tempfile::TempDir, SqliteWorkspaceStore) { + let (directory, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "tracking", "open"); + store.with_conn(|conn| { + conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) VALUES('w','r','Runtime','https://runtime.invalid','verified-fixture-key','verified-fixture-fingerprint','tracking-binding','workspace_identity','1','1')",[])?; + conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",[crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY])?; + conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','replacement','Replacement','normal','1','1')",[])?; + Ok(()) + }).unwrap(); + (directory, store) +} + +fn tracking_store_assign( + store: &SqliteWorkspaceStore, + id: &str, + operation: &str, +) -> TicketRoleAssignmentRecord { + let assignment = TicketRoleAssignmentRecord { + assignment_id: id.into(), + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + ..manual_worker_assignment("tracking", id) + }; + store + .set_current_ticket_role_assignment( + &assignment, + None, + &format!("event-{operation}"), + operation, + false, + ) + .unwrap() +} +fn tracking_store_input(id: &str, event: &str, key: &str) -> TicketAssignmentTrackingUpdate { + TicketAssignmentTrackingUpdate { + assignment_id: id.into(), + expected_last_event_id: event.into(), + operation_key: key.into(), + tracking: TicketAssignmentTracking { + status: TicketAssignmentTrackingStatus::Done, + waiting_reason: None, + progress: None, + next_action: None, + result: Some("Returned alternatives".into()), + }, + } +} + +#[tokio::test] +async fn tracking_store_every_replay_fences_current_singleton_and_authenticated_binding_under_write_lock() + { + for change in ["replacement", "revoke", "binding-change", "remove"] { + let (directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let input = tracking_store_input("a", "event-assign-a", "result-a"); + let result = store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(); + assert_eq!( + store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(), + result + ); + // Model the authority change after authenticated source capture, before + // BEGIN IMMEDIATE on a second connection. Receipt presence cannot bypass it. + let second = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); + second.with_conn_mut(|conn| { + let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + match change { + "replacement" => {tx.execute("UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w'",[])?;}, + "revoke" => {tx.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",[])?;}, + "binding-change" => {tx.execute("UPDATE workspace_runtime_bindings SET binding_id='new-binding' WHERE workspace_id='w'",[])?;}, + _ => {tx.execute("DELETE FROM worker_singleton_owners WHERE workspace_id='w'",[])?;}, + } + tx.commit()?;Ok(()) + }).unwrap(); + assert!( + matches!( + store.update_ticket_assignment_tracking("w", "tracking", &input, &source), + Err(Error::WorkspacePermissionDenied(_)) + ), + "reject receipt after {change}" + ); + let next = tracking_store_input( + "a", + result.last_event_id.as_deref().unwrap(), + "fresh-after-change", + ); + assert!( + matches!( + store.update_ticket_assignment_tracking("w", "tracking", &next, &source), + Err(Error::WorkspacePermissionDenied(_)) + ), + "reject fresh input after {change}" + ); + assert_eq!( + store + .list_ticket_assignment_tracking_history("w", "tracking", "a") + .unwrap() + .len(), + 2 + ); + } +} + +#[tokio::test] +async fn tracking_store_same_principal_reissue_and_exact_retries_keep_original_result_and_release_audit() + { + let (directory, store) = tracking_store_fixture().await; + let first = tracking_store_assign(&store, "a", "assign-a"); + let input = tracking_store_input("a", "event-assign-a", "result-a"); + let source = tracking_store_source(); + let result = store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(); + let second = tracking_store_assign(&store, "b", "assign-b"); + assert_eq!( + store + .set_current_ticket_role_assignment( + &second, + None, + "ignored-retry-event", + "assign-b", + false + ) + .unwrap(), + second + ); + assert_eq!( + store + .set_current_ticket_role_assignment( + &first, + None, + "ignored-original-retry", + "assign-a", + false + ) + .unwrap(), + first + ); + let old = store + .get_ticket_assignment_tracking("w", "tracking", "a") + .unwrap() + .unwrap(); + assert!(!old.current); + assert_eq!(old.tracking, result.tracking); + assert_eq!(old.assigned_by, first.assigned_by); + assert_eq!(old.assigned_at, first.assigned_at); + assert_eq!(old.role, "orchestrator"); + let release = old.release.as_ref().unwrap(); + assert_eq!( + release.action, + ticket::TicketAssignmentReleaseAction::Replaced + ); + assert_eq!(release.replacement_assignment_id.as_deref(), Some("b")); + assert_eq!(release.operation_key.as_deref(), Some("assign-b")); + assert_eq!(release.last_tracking, old.tracking); + assert_eq!( + store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(), + result + ); + assert!( + store + .update_ticket_assignment_tracking( + "w", + "tracking", + &tracking_store_input("a", result.last_event_id.as_deref().unwrap(), "stale-new"), + &source + ) + .is_err() + ); + let current = store + .get_ticket_assignment_tracking("w", "tracking", "b") + .unwrap() + .unwrap(); + assert_eq!( + current.tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + assert!( + store + .clear_current_ticket_role_assignment( + "w", + "tracking", + TicketAssignmentRole::Orchestrator, + "b", + "unassign-event", + "unassign-op", + "human", + "3", + Some("Returned to human") + ) + .unwrap() + ); + let removed = store + .get_ticket_assignment_tracking("w", "tracking", "b") + .unwrap() + .unwrap(); + assert_eq!( + removed.release.as_ref().unwrap().action, + ticket::TicketAssignmentReleaseAction::Unassigned + ); + assert_eq!( + removed.release.as_ref().unwrap().reason.as_deref(), + Some("Returned to human") + ); + assert_eq!(removed.tracking, current.tracking); + assert!( + store + .update_ticket_assignment_tracking( + "w", + "tracking", + &tracking_store_input("b", "event-assign-b", "removed-new"), + &source + ) + .is_err() + ); + drop(store); + let reopened = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); + assert_eq!( + reopened + .get_ticket_assignment_tracking("w", "tracking", "a") + .unwrap() + .unwrap(), + old + ); + reopened + .with_conn(|conn| { + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| { + r.get::<_, i64>(0) + })?, + 2 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_operations", + [], + |r| r.get::<_, i64>(0) + )?, + 3 + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn tracking_store_close_reopen_and_tracking_done_never_restore_grants_or_work_release() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + store.with_conn(|conn| {conn.execute("UPDATE typed_tickets SET workflow_state='closed' WHERE workspace_id='w' AND ticket_id='tracking'",[])?;Ok(())}).unwrap(); + let input = tracking_store_input("a", "event-assign-a", "closed-result"); + let done = store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(); + store.with_conn(|conn| {assert_eq!(conn.query_row("SELECT workflow_state FROM typed_tickets WHERE ticket_id='tracking'",[],|r|r.get::<_,String>(0))?,"closed");conn.execute("UPDATE typed_tickets SET workflow_state='open' WHERE workspace_id='w' AND ticket_id='tracking'",[])?;assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_work_releases WHERE assignment_id='a'",[],|r|r.get::<_,i64>(0))?,1);assert_eq!(conn.query_row("SELECT COUNT(*) FROM worker_control_grants",[],|r|r.get::<_,i64>(0))?,0);Ok(())}).unwrap(); + assert!( + store + .get_active_ticket_role_assignment("w", "tracking", TicketAssignmentRole::Orchestrator) + .unwrap() + .is_none() + ); + assert_eq!( + store + .get_ticket_assignment_tracking("w", "tracking", "a") + .unwrap() + .unwrap(), + done + ); + assert_eq!( + store + .update_ticket_assignment_tracking("w", "tracking", &input, &source) + .unwrap(), + done + ); +} + +fn tracking_schema91_fixture(path: &Path) -> Connection { + let conn = Connection::open(path).unwrap(); + configure_sqlite(&conn).unwrap(); + // Genuine retained component-10 DDL, before assignment tracking existed. + // The host structure is unchanged from Workspace 91: no tracking columns, + // tracking tables/triggers, or component-11 marker are created here. + conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql")) + .unwrap(); + conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','original-component10-time'); PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;").unwrap(); + create_workspace_schema_structure(&conn).unwrap(); + conn.execute( + "INSERT INTO __yoi_schema_migrations(version,name) VALUES(91,'workspace schema baseline')", + [], + ) + .unwrap(); + conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;") + .unwrap(); + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES('tracking-owner','user','tracking-owner','Owner','1','1'); INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) VALUES('w','tracking-owner','Workspace','active','1','1'); INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','worker','Worker','normal','1','1');").unwrap(); + for (ticket, state, snapshot, fence) in [ + ("pending", "open", Some("queued"), Some("a-pending")), + ("progress", "open", Some("inprogress"), Some("a-progress")), + ( + "invalid-fence", + "open", + Some("queued"), + Some("not-the-current-identity"), + ), + ("no-snapshot", "open", None, Some("a-no-snapshot")), + ( + "closed-reopen", + "open", + Some("queued"), + Some("a-closed-reopen"), + ), + ("replaced", "open", Some("queued"), Some("a-replaced")), + ( + "historical-done", + "closed", + Some("done"), + Some("a-historical-done"), + ), + ] { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w',?1,?1,'Title',?2,'task','P2','Original body',?2,1)",params![ticket,state]).unwrap(); + let assignment = format!("a-{ticket}"); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w',?1,?2,'orchestrator','workspace_agent','workspace-orchestrator','original-assign-actor','2026-10-10T00:00:00Z')",params![ticket,assignment]).unwrap(); + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w',?1,?2,'orchestrator','workspace_agent','workspace-orchestrator','2026-10-10T00:00:00Z')",params![ticket,assignment]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,actor,created_at,operation_id) VALUES('w',?1,'orchestrator',?2,'assigned',?3,'original-assign-actor','2026-10-10T00:00:00Z',?4)",params![ticket,format!("assigned-{ticket}"),assignment,format!("original-op-{ticket}")]).unwrap(); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,principal_id,assignment_id,created_at,request_fingerprint) VALUES('w',?1,'assign',?2,'orchestrator','workspace_agent','workspace-orchestrator',?3,'2026-10-10T00:00:00Z','original-operation-fingerprint')",params![format!("original-op-{ticket}"),ticket,assignment]).unwrap(); + if let Some(state) = snapshot { + let original = ticket::TicketLegacyWorkflowSnapshot { + workflow_state: state.into(), + workflow_state_explicit: true, + status: state.into(), + queued_by: Some("original-queue-actor".into()), + queued_at: Some("2026-10-10T00:01:00Z".into()), + resolution: None, + }; + conn.execute( + "INSERT INTO typed_ticket_legacy_workflow_snapshot VALUES('w',?1,?2)", + params![ticket, serde_json::to_string(&original).unwrap()], + ) + .unwrap(); + } + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,body) VALUES('w',?1,0,'state_changed','original-queue-actor','2026-10-10T00:01:00Z','ready','queued','Original Queue intent')",[ticket]).unwrap(); + if let Some(fence) = fence { + conn.execute("INSERT INTO typed_ticket_event_attributes VALUES('w',?1,0,'orchestrator_assignment_id',?2)",params![ticket,fence]).unwrap(); + } + } + // A pending worker startup reservation is operational data, not tracking. + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,principal_kind,runtime_id,worker_id,created_at,request_fingerprint,claim_state,binding_recovery_json) VALUES('w','pending-startup-claim','assign','pending','worker','worker','r','worker','original-claim-time','pending-claim-fingerprint','pending','{}')",[]).unwrap(); + for (ordinal, action) in [ + ( + 0, + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + ), + (1, ticket::TicketCaseAction::Reopen), + ] { + let key = format!("original-case-{ordinal}"); + let history = ticket::TicketCaseHistoryEntry { + operation_key: key.clone(), + previous_closure_operation_key: if ordinal == 1 { + Some("original-case-0".into()) + } else { + None + }, + action, + explanation: "Original later judgment".into(), + references: vec![], + author: "original-case-actor".into(), + at: format!("2026-10-10T00:0{}:00Z", ordinal + 2), + }; + conn.execute("INSERT INTO typed_ticket_case_history VALUES('w','closed-reopen',?1,?2,'original-case-fingerprint',?3)",params![ordinal,key,serde_json::to_string(&history).unwrap()]).unwrap(); + } + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,assigned_by,assigned_at) VALUES('w','replaced','b-replaced','orchestrator','workspace_agent','workspace-orchestrator','original-reassign-actor','2026-10-10T00:05:00Z')",[]).unwrap(); + conn.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id='w' AND ticket_id='replaced'",[]).unwrap(); + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,updated_at) VALUES('w','replaced','b-replaced','orchestrator','workspace_agent','workspace-orchestrator','2026-10-10T00:05:00Z')",[]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,role,event_id,action,assignment_id,previous_assignment_id,actor,created_at,operation_id) VALUES('w','replaced','orchestrator','original-reassign-event','reassigned','b-replaced','a-replaced','original-reassign-actor','2026-10-10T00:05:00Z','original-reassign-op')",[]).unwrap(); + assert!(!table_exists(&conn, "typed_ticket_assignment_tracking_history").unwrap()); + assert_eq!( + conn.query_row( + "SELECT MAX(version) FROM ticket_schema_migrations", + [], + |r| r.get::<_, i64>(0) + ) + .unwrap(), + 10 + ); + assert_eq!(current_schema_version(&conn).unwrap(), 91); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + )) + .unwrap(), + 0 + ); + conn +} + +#[test] +fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receipts_and_claims() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("retained91.db"); + let conn = tracking_schema91_fixture(&path); + let retained = [ + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_legacy_workflow_snapshot", + "typed_ticket_case_history", + "ticket_worker_assignments", + "ticket_current_worker_assignments", + "ticket_worker_assignment_events", + "ticket_assignment_operations", + ]; + let original = retained.map(|table| stored_fixture_rows(&conn, table)); + drop(conn); + let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); + assert_eq!(plan.current_schema_version, 91); + assert_eq!( + plan.migrations + .iter() + .map(|m| m.version) + .collect::>(), + vec![92, 93] + ); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 93); + assert_eq!( + conn.query_row( + "SELECT applied_at FROM ticket_schema_migrations WHERE version=10", + [], + |r| r.get::<_, String>(0) + )?, + "original-component10-time" + ); + assert_eq!( + workspace_schema_migration_history(conn)?, + vec![ + (91, "workspace schema baseline".into()), + (92, "Ticket-owned assignment tracking".into()), + ( + 93, + "Durable assignment deliveries and source-bound startup fences".into() + ) + ] + ); + for (table, rows) in retained.iter().zip(original.iter()) { + assert_eq!( + &stored_fixture_rows(conn, table), + rows, + "preserve original {table}" + ); + } + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |r| r + .get::<_, i64>( + 0 + ))?, + 0 + ); + Ok(()) + }) + .unwrap(); + for (ticket, status) in [ + ("pending", Some(TicketAssignmentTrackingStatus::Waiting)), + ("progress", Some(TicketAssignmentTrackingStatus::Inprogress)), + ("invalid-fence", None), + ("no-snapshot", None), + ("closed-reopen", None), + ("replaced", None), + ("historical-done", None), + ] { + let value = store + .get_ticket_assignment_tracking("w", ticket, &format!("a-{ticket}")) + .unwrap() + .unwrap(); + assert_eq!( + value.tracking.as_ref().map(|t| t.status), + status, + "{ticket}" + ); + assert_eq!(value.assigned_by, "original-assign-actor"); + assert_eq!(value.assigned_at, "2026-10-10T00:00:00Z"); + assert!(value.migration_diagnostic.is_some()); + } + let replaced = store + .get_ticket_assignment_tracking("w", "replaced", "a-replaced") + .unwrap() + .unwrap(); + assert_eq!( + replaced.release.as_ref().unwrap().event_id, + "original-reassign-event" + ); + assert!(replaced.release.as_ref().unwrap().last_tracking.is_none()); + let pending = store + .get_ticket_assignment_operation("w", "pending-startup-claim") + .unwrap() + .unwrap(); + assert_eq!(pending.claim_state, "pending"); + assert_eq!( + pending.request_fingerprint.as_deref(), + Some("pending-claim-fingerprint") + ); + let history = store + .list_ticket_assignment_tracking_history("w", "pending", "a-pending") + .unwrap(); + drop(store); + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + assert_eq!( + reopened + .list_ticket_assignment_tracking_history("w", "pending", "a-pending") + .unwrap(), + history + ); +} + +#[test] +fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first_self_report() { + use ticket::TicketBackend; + + for report_after_reopen in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("closed-unknown91.db"); + let conn = tracking_schema91_fixture(&path); + conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) VALUES('w','r','Runtime','https://runtime.invalid','verified-fixture-key','verified-fixture-fingerprint','tracking-binding','workspace_identity','1','1')",[]).unwrap(); + conn.execute("INSERT INTO worker_singleton_owners(workspace_id,singleton_key,runtime_id,worker_id,generation,created_at,updated_at) VALUES('w',?1,'r','worker',1,'1','1')",[crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap(); + conn.execute("INSERT INTO worker_control_grants(workspace_id,grant_id,controller_runtime_id,controller_worker_id,subject_runtime_id,subject_worker_id,relation,origin,permissions_json,operation_id,created_at,revoked_at) VALUES('w','old-revoked-grant','r','worker','r','worker','ticket','user','[\"observe\"]','old-grant-op','1','original-revocation')",[]).unwrap(); + let audit_before = stored_fixture_rows(&conn, "ticket_worker_assignment_events"); + let grants_before = stored_fixture_rows(&conn, "worker_control_grants"); + let releases_before = stored_fixture_rows(&conn, "ticket_assignment_work_releases"); + drop(conn); + + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap(); + let reopen = || { + let observed = backend.show("historical-done".into()).unwrap(); + backend + .update_case( + "historical-done", + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&observed), + expected_state: ticket::TicketState::Closed, + expected_closure_operation_key: observed.closure_operation_key.clone(), + operation_key: "explicit-reopen".into(), + action: ticket::TicketCaseAction::Reopen, + explanation: "Reopen without redelegation or restoring old work".into(), + references: vec![], + author: Some("account-author".into()), + }, + ) + .unwrap(); + }; + let unknown = store + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(); + assert!(unknown.current); + assert!(unknown.tracking.is_none()); + assert!(unknown.updated_at.is_none()); + assert!(unknown.migration_diagnostic.is_some()); + assert_eq!( + unknown.last_event_id.as_deref(), + Some("assigned-historical-done") + ); + assert_eq!( + store + .list_current_ticket_assignment_tracking("w", "historical-done") + .unwrap(), + vec![unknown.clone()] + ); + assert!( + store + .list_ticket_assignment_tracking_history( + "w", + "historical-done", + "a-historical-done" + ) + .unwrap() + .is_empty() + ); + store.with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 93); + assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_,i64>(0))?, 11); + assert_eq!(conn.query_row("SELECT workflow_state FROM typed_tickets WHERE ticket_id='historical-done'", [], |row| row.get::<_,String>(0))?, "closed"); + let stored: (Option, Option) = conn.query_row("SELECT tracking_json,last_tracking_event_id FROM ticket_worker_assignments WHERE assignment_id='a-historical-done'", [], |row| Ok((row.get(0)?,row.get(1)?)))?; + assert_eq!(stored, (None, None), "read baseline must not manufacture tracking storage"); + assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before); + Ok(()) + }).unwrap(); + if report_after_reopen { + reopen(); + } + let source = tracking_store_source(); + let input = tracking_store_input( + "a-historical-done", + unknown.last_event_id.as_deref().unwrap(), + "first-explicit-result", + ); + let mut wrong_source = source.clone(); + wrong_source.worker_id = "not-current-orchestrator".into(); + assert!(matches!( + store.update_ticket_assignment_tracking("w", "historical-done", &input, &wrong_source), + Err(Error::WorkspacePermissionDenied(_)) + )); + for (workspace, ticket) in [("other-workspace", "historical-done"), ("w", "pending")] { + assert!( + store + .update_ticket_assignment_tracking(workspace, ticket, &input, &source) + .is_err() + ); + } + assert!( + store + .update_ticket_assignment_tracking( + "w", + "replaced", + &tracking_store_input( + "a-replaced", + "assigned-replaced", + "wrong-old-assignment" + ), + &source + ) + .is_err() + ); + // A second observer holds the same Assign baseline. Only the first + // explicit writer may consume it; BEGIN IMMEDIATE serializes the CAS. + let second = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + assert_eq!( + second + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(), + unknown + ); + let done = store + .update_ticket_assignment_tracking("w", "historical-done", &input, &source) + .unwrap(); + assert_eq!( + done.tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Done + ); + assert_eq!(done.assignment_id, unknown.assignment_id); + assert_ne!(done.last_event_id, unknown.last_event_id); + assert_eq!( + store + .update_ticket_assignment_tracking("w", "historical-done", &input, &source) + .unwrap(), + done + ); + assert!(matches!( + second.update_ticket_assignment_tracking( + "w", + "historical-done", + &tracking_store_input( + "a-historical-done", + "assigned-historical-done", + "stale-second-observer" + ), + &source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert_eq!( + backend + .show("historical-done".into()) + .unwrap() + .meta + .workflow_state, + if report_after_reopen { + ticket::TicketState::Open + } else { + ticket::TicketState::Closed + } + ); + if !report_after_reopen { + reopen(); + } + let after_reopen = store + .update_ticket_assignment_tracking( + "w", + "historical-done", + &tracking_store_input( + "a-historical-done", + done.last_event_id.as_deref().unwrap(), + "explicit-result-after-reopen", + ), + &source, + ) + .unwrap(); + let history = store + .list_ticket_assignment_tracking_history("w", "historical-done", "a-historical-done") + .unwrap(); + assert_eq!( + history.len(), + 2, + "only explicit self-reports create handling events" + ); + assert_eq!(history[0].event_id, done.last_event_id.clone().unwrap()); + assert_eq!( + history[1].event_id, + after_reopen.last_event_id.clone().unwrap() + ); + assert!( + store + .get_active_ticket_role_assignment( + "w", + "historical-done", + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id='w' AND ticket_id='historical-done'", [], |row| row.get::<_,i64>(0))?, 1); + assert_eq!(stored_fixture_rows(conn, "ticket_worker_assignment_events"), audit_before); + assert_eq!(stored_fixture_rows(conn, "worker_control_grants"), grants_before); + assert_eq!(stored_fixture_rows(conn, "ticket_assignment_work_releases"), releases_before); + Ok(()) + }).unwrap(); + drop(second); + drop(store); + let reopened_store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + assert_eq!( + reopened_store + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(), + after_reopen + ); + assert!( + reopened_store + .clear_current_ticket_orchestrator_assignment( + "w", + "historical-done", + "a-historical-done", + "explicit-release-event", + "explicit-release", + "human", + "3", + Some("Return responsibility"), + &orchestrator_mutation_runtime_source() + ) + .unwrap() + ); + assert!(matches!( + reopened_store.update_ticket_assignment_tracking( + "w", + "historical-done", + &tracking_store_input( + "a-historical-done", + after_reopen.last_event_id.as_deref().unwrap(), + "new-report-after-release" + ), + &source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + let released = reopened_store + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(); + assert!(!released.current); + assert_eq!(released.tracking, after_reopen.tracking); + } +} + +#[test] +fn tracking_store_schema91_missing_assign_audit_remains_unknown_without_cas_baseline() { + let directory = tempfile::tempdir().unwrap(); + let path = directory.path().join("missing-audit91.db"); + let conn = tracking_schema91_fixture(&path); + conn.execute( + "DELETE FROM ticket_worker_assignment_events WHERE event_id='assigned-historical-done'", + [], + ) + .unwrap(); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let unknown = store + .get_ticket_assignment_tracking("w", "historical-done", "a-historical-done") + .unwrap() + .unwrap(); + assert!(unknown.current); + assert!(unknown.tracking.is_none()); + assert!(unknown.last_event_id.is_none()); + assert!(unknown.updated_at.is_none()); + assert!(unknown.migration_diagnostic.is_some()); + assert!( + store + .list_ticket_assignment_tracking_history("w", "historical-done", "a-historical-done") + .unwrap() + .is_empty() + ); + // Another assignment's real audit is not a substitute for missing evidence. + store + .with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + assert!( + ticket::sqlite_update_ticket_assignment_tracking_in_transaction( + &tx, + "w", + "historical-done", + &tracking_store_input( + "a-historical-done", + "assigned-pending", + "not-a-baseline" + ), + &tracking_store_source() + ) + .is_err() + ); + Ok(()) + }) + .unwrap(); +} + +fn orchestrator_mutation_runtime_source() -> TicketOrchestratorAssignmentSourceContext { + TicketOrchestratorAssignmentSourceContext::Runtime { + runtime_id: "r".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "tracking-binding".into(), + } +} +fn orchestrator_mutation_record(id: &str) -> TicketRoleAssignmentRecord { + TicketRoleAssignmentRecord { + assignment_id: id.into(), + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + assigned_by: "readable Backend attribution".into(), + ..manual_worker_assignment("tracking", id) + } +} + +#[tokio::test] +async fn orchestrator_assignment_receipts_bind_verified_source_even_when_readable_actor_is_unchanged() + { + let (_directory, store) = tracking_store_fixture().await; + let source = orchestrator_mutation_runtime_source(); + let record = orchestrator_mutation_record("source-a"); + let assigned = store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "source-event", + "source-assign", + false, + &source, + ) + .unwrap(); + assert_eq!( + store + .set_current_ticket_orchestrator_assignment( + &orchestrator_mutation_record("ignored-result-id"), + None, + "ignored-event", + "source-assign", + false, + &source + ) + .unwrap(), + assigned + ); + assert!( + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "source-clear-event", + "source-clear", + "readable clear actor", + "3", + Some("Returned"), + &source + ) + .unwrap() + ); + let second = orchestrator_mutation_record("source-b"); + store + .set_current_ticket_orchestrator_assignment( + &second, + None, + "second-event", + "second-assign", + false, + &source, + ) + .unwrap(); + // Exact old operations preserve original results after assignment movement. + assert_eq!( + store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "source-assign", + false, + &source + ) + .unwrap(), + assigned + ); + assert!( + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "ignored-clear-event", + "source-clear", + "readable clear actor", + "later-time", + Some("Returned"), + &source + ) + .unwrap() + ); + assert!( + !store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "new-stale-event", + "new-stale-clear", + "readable clear actor", + "later-time", + Some("Returned"), + &source + ) + .unwrap() + ); + assert!( + store + .get_ticket_assignment_operation("w", "new-stale-clear") + .unwrap() + .is_none() + ); + store.with_conn(|conn| {conn.execute("UPDATE workspace_runtime_bindings SET binding_id='replacement-binding' WHERE workspace_id='w' AND runtime_id='r'",[])?;Ok(())}).unwrap(); + let new_source = TicketOrchestratorAssignmentSourceContext::Runtime { + authentication_binding_id: "replacement-binding".into(), + runtime_id: "r".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + }; + assert!(matches!( + store.set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "source-assign", + false, + &new_source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert!(matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "ignored-event", + "source-clear", + "readable clear actor", + "later-time", + Some("Returned"), + &new_source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert!(matches!( + store.set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "source-assign", + false, + &source + ), + Err(Error::WorkspacePermissionDenied(_)) + )); + assert!(matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "ignored-event", + "source-clear", + "readable clear actor", + "later-time", + Some("Returned"), + &source + ), + Err(Error::WorkspacePermissionDenied(_)) + )); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments", [], |r| { + r.get::<_, i64>(0) + })?, + 2 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignment_events", + [], + |r| r.get::<_, i64>(0) + )?, + 3 + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn orchestrator_assignment_all_runtime_retries_fence_singleton_and_binding_in_write_transaction() + { + for change in ["replace", "revoke", "remove"] { + let (directory, store) = tracking_store_fixture().await; + let source = orchestrator_mutation_runtime_source(); + let record = orchestrator_mutation_record("source-a"); + store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "assigned-event", + "assigned-operation", + false, + &source, + ) + .unwrap(); + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "clear-event", + "clear-operation", + "human-readable actor", + "3", + None, + &source, + ) + .unwrap(); + let second = SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")).unwrap(); + second.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?;match change { + "replace" => {tx.execute("UPDATE worker_singleton_owners SET worker_id='replacement',generation=2 WHERE workspace_id='w'",[])?;}, + "revoke" => {tx.execute("UPDATE workspace_runtime_bindings SET revoked_at='revoked' WHERE workspace_id='w'",[])?;}, + _ => {tx.execute("DELETE FROM worker_singleton_owners WHERE workspace_id='w'",[])?;}, + }tx.commit()?;Ok(())}).unwrap(); + assert!( + matches!( + store.set_current_ticket_orchestrator_assignment( + &record, + None, + "retry-event", + "assigned-operation", + false, + &source + ), + Err(Error::WorkspacePermissionDenied(_)) + ), + "assign after {change}" + ); + assert!( + matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "source-a", + "retry-clear-event", + "clear-operation", + "human-readable actor", + "4", + None, + &source + ), + Err(Error::WorkspacePermissionDenied(_)) + ), + "clear after {change}" + ); + } +} + +#[tokio::test] +async fn orchestrator_assignment_account_authoring_is_trusted_source_bound_and_never_requires_a_runtime() + { + let (_directory, store) = tracking_store_fixture().await; + store + .with_conn(|conn| { + conn.execute("DELETE FROM worker_singleton_owners", [])?; + conn.execute( + "UPDATE workspace_runtime_bindings SET revoked_at='revoked'", + [], + )?; + Ok(()) + }) + .unwrap(); + let source = TicketOrchestratorAssignmentSourceContext::Account { + account_id: "owner-account".into(), + user_id: "authenticated-user".into(), + }; + let record = orchestrator_mutation_record("account-a"); + let assigned = store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "account-event", + "account-assign", + false, + &source, + ) + .unwrap(); + assert_eq!(assigned.assigned_by, "readable Backend attribution"); + assert_eq!( + store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "account-assign", + false, + &source + ) + .unwrap(), + assigned + ); + let different = TicketOrchestratorAssignmentSourceContext::Account { + account_id: "owner-account".into(), + user_id: "different-authenticated-user".into(), + }; + assert!(matches!( + store.set_current_ticket_orchestrator_assignment( + &record, + None, + "ignored-event", + "account-assign", + false, + &different + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert!( + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "account-a", + "account-clear-event", + "account-clear", + "account display actor", + "3", + None, + &source + ) + .unwrap() + ); + assert!(matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "account-a", + "ignored-event", + "account-clear", + "account display actor", + "4", + None, + &different + ), + Err(Error::TicketAssignmentConflict(_)) + )); +} + +#[tokio::test] +async fn tracking_history_pages_are_append_ordered_exact_scope_cursors_and_bounded_limits() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "history-a", "history-assign-a"); + let mut expected = vec!["event-history-assign-a".to_owned()]; + store.with_conn_mut(|conn| {let tx=conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + for index in 0..204 { + let event=format!("reverse-event-{:03}",204-index);expected.push(event.clone()); + tx.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','tracking','history-a',?1,?2,'{\"status\":\"waiting\"}','{\"kind\":\"fixture\"}','same-at')",params![event,format!("fixture-operation-{index}")])?; + }tx.commit()?;Ok(()) + }).unwrap(); + let first = store + .page_ticket_assignment_tracking_history("w", "tracking", "history-a", None, 100) + .unwrap(); + assert_eq!(first.items.len(), 100); + assert_eq!( + first + .items + .iter() + .map(|e| e.event_id.clone()) + .collect::>(), + expected[..100] + ); + assert_eq!(first.next_event_id.as_deref(), Some(expected[99].as_str())); + let second = store + .page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + first.next_event_id.as_deref(), + 100, + ) + .unwrap(); + assert_eq!(second.items.len(), 100); + assert_eq!( + second + .items + .iter() + .map(|e| e.event_id.clone()) + .collect::>(), + expected[100..200] + ); + let third = store + .page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + second.next_event_id.as_deref(), + 100, + ) + .unwrap(); + assert_eq!( + third + .items + .iter() + .map(|e| e.event_id.clone()) + .collect::>(), + expected[200..] + ); + assert!(third.next_event_id.is_none()); + let after_last = store + .page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + expected.last().map(String::as_str), + 1, + ) + .unwrap(); + assert!(after_last.items.is_empty()); + assert!(after_last.next_event_id.is_none()); + let one = store + .page_ticket_assignment_tracking_history("w", "tracking", "history-a", None, 1) + .unwrap(); + assert_eq!(one.items.len(), 1); + assert_eq!(one.next_event_id.as_deref(), Some(expected[0].as_str())); + for limit in [0, 101, usize::MAX] { + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + None, + limit + ), + Err(Error::InvalidInput(_)) + )); + } + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("missing"), + 10 + ), + Err(Error::InvalidInput(_)) + )); + tracking_store_assign(&store, "history-b", "history-assign-b"); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("event-history-assign-b"), + 10 + ), + Err(Error::InvalidInput(_)) + )); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "other", + "tracking", + "history-a", + Some(&expected[0]), + 10 + ), + Err(Error::InvalidInput(_)) + )); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "wrong-ticket", + "history-a", + Some(&expected[0]), + 10 + ), + Err(Error::InvalidInput(_)) + )); + seed_manual_start_ticket(&store, "another-ticket", "open"); + let other_ticket = TicketRoleAssignmentRecord { + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + ..manual_worker_assignment("another-ticket", "other-ticket-assignment") + }; + store + .set_current_ticket_role_assignment( + &other_ticket, + None, + "other-ticket-cursor", + "other-ticket-operation", + false, + ) + .unwrap(); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("other-ticket-cursor"), + 10 + ), + Err(Error::InvalidInput(_)) + )); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "other".into(), + owner_account_id: "owner-account".into(), + display_name: "Other".into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .await + .unwrap(); + store.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('other','tracking','tracking','Other','open','task','P2','','open',1)",[])?;Ok(())}).unwrap(); + let other_workspace = TicketRoleAssignmentRecord { + workspace_id: "other".into(), + ..orchestrator_mutation_record("history-a") + }; + store + .set_current_ticket_role_assignment( + &other_workspace, + None, + "other-workspace-cursor", + "other-workspace-operation", + false, + ) + .unwrap(); + assert!(matches!( + store.page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("other-workspace-cursor"), + 10 + ), + Err(Error::InvalidInput(_)) + )); + // Retained history can still be read after assignment replacement. + assert_eq!( + store + .page_ticket_assignment_tracking_history("w", "tracking", "history-a", None, 100) + .unwrap(), + first + ); +} + +#[tokio::test] +async fn tracking_history_sql_limit_does_not_deserialize_unrequested_lifetime_rows() { + let (_directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "history-a", "history-assign-a"); + store.with_conn(|conn| { + conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','tracking','history-a','safe-lookahead','safe-operation','{\"status\":\"waiting\"}','{}','same-at')",[])?; + // Simulate a later imported/corrupt domain payload: valid SQLite JSON, + // but not a tracking DTO. It must not poison an earlier bounded page. + conn.execute("INSERT INTO typed_ticket_assignment_tracking_history VALUES('w','tracking','history-a','later-corrupt-event','later-corrupt-operation','{\"status\":\"not-a-tracking-status\"}','{}','same-at')",[])?;Ok(()) + }).unwrap(); + let page = store + .page_ticket_assignment_tracking_history("w", "tracking", "history-a", None, 1) + .unwrap(); + assert_eq!(page.items.len(), 1); + assert_eq!(page.items[0].event_id, "event-history-assign-a"); + assert_eq!( + page.next_event_id.as_deref(), + Some("event-history-assign-a") + ); + assert!( + store + .page_ticket_assignment_tracking_history( + "w", + "tracking", + "history-a", + Some("safe-lookahead"), + 1 + ) + .is_err() + ); +} + +#[tokio::test] +async fn orchestrator_clear_receipt_compares_nullable_reason_as_full_payload_not_only_readable_text() + { + let (_directory, store) = tracking_store_fixture().await; + let source = orchestrator_mutation_runtime_source(); + let record = orchestrator_mutation_record("reason-a"); + store + .set_current_ticket_orchestrator_assignment( + &record, + None, + "reason-assign-event", + "reason-assign", + false, + &source, + ) + .unwrap(); + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "reason-a", + "reason-clear-event", + "reason-clear", + "actor", + "3", + None, + &source, + ) + .unwrap(); + assert!(matches!( + store.clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "reason-a", + "ignored-event", + "reason-clear", + "actor", + "4", + Some(""), + &source + ), + Err(Error::TicketAssignmentConflict(_)) + )); + assert!( + store + .clear_current_ticket_orchestrator_assignment( + "w", + "tracking", + "reason-a", + "ignored-event", + "reason-clear", + "actor", + "4", + None, + &source + ) + .unwrap() + ); +} + +fn assignment_answer_input(key: &str) -> ticket::TicketAssignmentAnswer { + ticket::TicketAssignmentAnswer { + assignment_id: "answer-a".into(), + expected_last_event_id: "event-answer-assign".into(), + operation_key: key.into(), + body: "Proceed with option B".into(), + } +} +fn assignment_answer_source() -> TicketOrchestratorAssignmentSourceContext { + TicketOrchestratorAssignmentSourceContext::Runtime { + runtime_id: "r".into(), + worker_id: "worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "tracking-binding".into(), + } +} + +#[tokio::test] +async fn assignment_answer_replay_returns_saved_event_without_new_assignment_or_status_change() { + let (dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "answer-a", "answer-assign"); + let input = assignment_answer_input("answer-key"); + let before = store + .get_ticket_assignment_tracking("w", "tracking", "answer-a") + .unwrap() + .unwrap(); + let saved = store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "authenticated-worker:r/worker", + ) + .unwrap(); + drop(store); + let store = SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + assert_eq!( + store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "authenticated-worker:r/worker" + ) + .unwrap(), + saved + ); + assert_eq!( + store + .get_ticket_assignment_tracking("w", "tracking", "answer-a") + .unwrap() + .unwrap(), + before + ); + let mut changed = input.clone(); + changed.body = "Different answer".into(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &changed, + &assignment_answer_source(), + "authenticated-worker:r/worker" + ) + .is_err() + ); + let pending = store + .list_pending_ticket_assignment_deliveries("w", 100) + .unwrap(); + assert_eq!(pending.len(), 2); + assert!( + pending + .iter() + .any(|p| p.input_event_id == saved.input_event_id) + ); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row( + "SELECT count(*) FROM typed_ticket_events WHERE kind='assignment_answer'", + [], + |r| r.get::<_, i64>(0) + )?, + 1 + ); + assert_eq!( + conn.query_row( + "SELECT value FROM typed_ticket_event_attributes WHERE key='operation_key'", + [], + |r| r.get::<_, String>(0) + )?, + "answer-key" + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_answer_receipt_failure_rolls_back_body_audit_and_ticket_touch() { + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "answer-a", "answer-assign"); + store.with_conn(|conn| { + conn.execute_batch("CREATE TRIGGER reject_answer_receipt BEFORE INSERT ON typed_ticket_assignment_tracking_operations BEGIN SELECT RAISE(ABORT,'receipt failed'); END;")?; + Ok(()) + }).unwrap(); + let before: String = store + .with_conn(|conn| { + Ok(conn.query_row( + "SELECT updated_at FROM typed_tickets WHERE ticket_id='tracking'", + [], + |r| r.get(0), + )?) + }) + .unwrap(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &assignment_answer_input("failed-key"), + &assignment_answer_source(), + "authenticated-worker:r/worker" + ) + .is_err() + ); + store + .with_conn(|conn| { + assert_eq!( + conn.query_row( + "SELECT count(*) FROM typed_ticket_events WHERE kind='assignment_answer'", + [], + |r| r.get::<_, i64>(0) + )?, + 0 + ); + assert_eq!( + conn.query_row( + "SELECT count(*) FROM typed_ticket_event_attributes WHERE key='operation_key'", + [], + |r| r.get::<_, i64>(0) + )?, + 0 + ); + assert_eq!( + conn.query_row( + "SELECT updated_at FROM typed_tickets WHERE ticket_id='tracking'", + [], + |r| r.get::<_, String>(0) + )?, + before + ); + Ok(()) + }) + .unwrap(); +} + +#[tokio::test] +async fn assignment_answer_new_input_rejects_replaced_or_changed_handling_but_exact_retry_is_saved() +{ + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "answer-a", "answer-assign"); + let input = assignment_answer_input("saved-answer"); + let saved = store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "actor", + ) + .unwrap(); + let mut update = tracking_store_input("answer-a", "event-answer-assign", "next-status"); + update.tracking.status = TicketAssignmentTrackingStatus::Waiting; + store + .update_ticket_assignment_tracking("w", "tracking", &update, &tracking_store_source()) + .unwrap(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &assignment_answer_input("stale-answer"), + &assignment_answer_source(), + "actor" + ) + .is_err() + ); + let mut replacement = manual_worker_assignment("tracking", "answer-b"); + replacement.role = TicketAssignmentRole::Orchestrator; + replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }; + store + .set_current_ticket_role_assignment( + &replacement, + Some("answer-a"), + "replace-answer", + "replace-answer-key", + true, + ) + .unwrap(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &assignment_answer_input("replaced-answer"), + &assignment_answer_source(), + "actor" + ) + .is_err() + ); + assert_eq!( + store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "actor" + ) + .unwrap(), + saved + ); + store + .with_conn(|conn| { + conn.execute( + "UPDATE worker_singleton_owners SET worker_id='replacement' WHERE workspace_id='w'", + [], + )?; + Ok(()) + }) + .unwrap(); + assert!( + store + .answer_ticket_assignment( + "w", + "tracking", + &input, + &assignment_answer_source(), + "actor" + ) + .is_err() + ); +} + +#[tokio::test] +async fn assignment_identity_pages_keep_release_and_last_status_separate_and_reject_foreign_cursor() +{ + let (_dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "z-first", "first"); + let mut replacement = manual_worker_assignment("tracking", "a-second"); + replacement.role = TicketAssignmentRole::Orchestrator; + replacement.principal = TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }; + store + .set_current_ticket_role_assignment( + &replacement, + Some("z-first"), + "replacement-event", + "replacement-key", + true, + ) + .unwrap(); + let page = store + .page_ticket_assignments("w", "tracking", None, 1) + .unwrap(); + assert_eq!(page.next_assignment_id.as_deref(), Some("z-first")); + assert!(!page.items[0].current); + assert_eq!( + page.items[0].tracking.as_ref().unwrap().status, + TicketAssignmentTrackingStatus::Waiting + ); + assert_eq!( + page.items[0].release.as_ref().unwrap().action, + ticket::TicketAssignmentReleaseAction::Replaced + ); + let next = store + .page_ticket_assignments("w", "tracking", page.next_assignment_id.as_deref(), 1) + .unwrap(); + assert_eq!(next.items[0].assignment_id, "a-second"); + assert!(next.items[0].current); + assert!(next.next_assignment_id.is_none()); + assert!( + store + .page_ticket_assignments("other", "tracking", Some("z-first"), 1) + .is_err() + ); + assert!( + store + .page_ticket_assignments("w", "tracking", None, 101) + .is_err() + ); +} + +#[tokio::test] +async fn assignment_answer_concurrent_exact_retry_on_two_connections_creates_one_event_and_receipt() +{ + let (dir, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "answer-a", "answer-assign"); + let path = dir.path().join("workspace.db"); + let first = SqliteWorkspaceStore::open(&path).unwrap(); + let second = SqliteWorkspaceStore::open(&path).unwrap(); + let barrier = std::sync::Barrier::new(2); + let results = std::thread::scope(|scope| { + let run = |store: &SqliteWorkspaceStore| { + barrier.wait(); + store + .answer_ticket_assignment( + "w", + "tracking", + &assignment_answer_input("concurrent-answer"), + &assignment_answer_source(), + "actor", + ) + .unwrap() + }; + let a = scope.spawn(move || run(&first)); + let b = scope.spawn(move || run(&second)); + (a.join().unwrap(), b.join().unwrap()) + }); + assert_eq!(results.0, results.1); + store.with_conn(|conn| { + assert_eq!(conn.query_row("SELECT count(*) FROM typed_ticket_events WHERE kind='assignment_answer'",[],|r| r.get::<_,i64>(0))?,1); + assert_eq!(conn.query_row("SELECT count(*) FROM typed_ticket_assignment_tracking_operations WHERE operation_key='concurrent-answer'",[],|r| r.get::<_,i64>(0))?,1); + Ok(()) + }).unwrap(); +} diff --git a/crates/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index 286e2ced8..6dd4770bc 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -98,12 +98,30 @@ pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option { }) } -pub async fn resolve_request_actor( - store: &S, +pub async fn resolve_request_actor( + store: std::sync::Arc, headers: &HeaderMap, cookie_name: &str, ) -> Result> { - if let Some(token) = parse_bearer(headers) { + let bearer = parse_bearer(headers); + let session = parse_cookie(headers, cookie_name); + if bearer.is_none() && session.is_none() { + return Ok(None); + } + let executor = store.db_executor(); + executor + .run(move || resolve_request_actor_sync(store.as_ref(), bearer, session)) + .await +} + +// One owned synchronous stage includes token usage marking and the user read. +// Do not call async store wrappers here: they use the same executor themselves. +fn resolve_request_actor_sync( + store: &dyn ControlPlaneStore, + bearer: Option, + session: Option, +) -> Result> { + if let Some(token) = bearer { let token_hash = token_hash(&token); if let Some(api_token) = store.resolve_api_token(&token_hash)? { if api_token @@ -118,7 +136,7 @@ pub async fn resolve_request_actor( } } - if let Some(session_token) = parse_cookie(headers, cookie_name) { + if let Some(session_token) = session { if let Some(session) = store.resolve_browser_session(&token_hash(&session_token))? { if is_expired(&session.expires_at) { return Ok(None); @@ -182,6 +200,182 @@ pub fn auth_error(code: &str, message: &str) -> Error { #[cfg(test)] mod tests { use super::*; + use crate::store::{ + AccountRecord, ApiTokenRecord, BrowserSessionRecord, SqliteWorkspaceStore, UserRecord, + }; + use std::sync::Arc; + + fn seeded_store() -> Arc { + let executor = crate::db_executor::DbExecutor::new(crate::db_executor::DbExecutorLimits { + workers: std::num::NonZeroUsize::new(1).unwrap(), + queued: std::num::NonZeroUsize::new(1).unwrap(), + }) + .unwrap(); + let store = Arc::new( + SqliteWorkspaceStore::in_memory_for_test() + .unwrap() + .with_db_executor(executor), + ); + store + .upsert_account(&AccountRecord { + account_id: "account-auth".into(), + kind: "user".into(), + handle: "auth".into(), + display_name: "Auth".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .unwrap(); + store + .upsert_user(&UserRecord { + user_id: "user-auth".into(), + account_id: "account-auth".into(), + handle: "auth".into(), + display_name: "Auth".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .unwrap(); + store + .create_browser_session(&BrowserSessionRecord { + session_id: "session-auth".into(), + token_hash: token_hash("session-secret"), + user_id: "user-auth".into(), + created_at: "1".into(), + expires_at: "9999-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + store + } + + #[tokio::test] + async fn api_token_auth_marks_usage_and_takes_precedence_over_cookie() { + let store = seeded_store(); + let hash = token_hash("api-secret"); + store + .create_api_token(&ApiTokenRecord { + token_id: "token-auth".into(), + token_hash: hash.clone(), + user_id: "user-auth".into(), + label: "Auth".into(), + created_at: "1".into(), + expires_at: None, + revoked_at: None, + last_used_at: None, + }) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert(header::AUTHORIZATION, "Bearer api-secret".parse().unwrap()); + headers.insert(header::COOKIE, "session=session-secret".parse().unwrap()); + let actor = resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .unwrap(); + assert_eq!(actor.user_id, "user-auth"); + assert_eq!(actor.auth_method, ActorAuthMethod::ApiToken); + assert!( + store + .resolve_api_token(&hash) + .unwrap() + .unwrap() + .last_used_at + .is_some() + ); + } + + #[tokio::test] + async fn expired_api_token_does_not_fall_back_to_valid_browser_session() { + let store = seeded_store(); + let hash = token_hash("expired-secret"); + store + .create_api_token(&ApiTokenRecord { + token_id: "token-auth".into(), + token_hash: hash.clone(), + user_id: "user-auth".into(), + label: "Auth".into(), + created_at: "1".into(), + expires_at: Some("2000-01-01T00:00:00Z".into()), + revoked_at: None, + last_used_at: None, + }) + .unwrap(); + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + "Bearer expired-secret".parse().unwrap(), + ); + headers.insert(header::COOKIE, "session=session-secret".parse().unwrap()); + assert!( + resolve_request_actor(store.clone(), &headers, "session") + .await + .unwrap() + .is_none() + ); + assert!( + store + .resolve_api_token(&hash) + .unwrap() + .unwrap() + .last_used_at + .is_none() + ); + } + + #[tokio::test] + async fn unknown_bearer_can_fall_back_to_valid_browser_session() { + let store = seeded_store(); + let mut headers = HeaderMap::new(); + headers.insert( + header::AUTHORIZATION, + "Bearer unknown-secret".parse().unwrap(), + ); + headers.insert(header::COOKIE, "session=session-secret".parse().unwrap()); + let actor = resolve_request_actor(store, &headers, "session") + .await + .unwrap() + .unwrap(); + assert_eq!(actor.user_id, "user-auth"); + assert_eq!(actor.auth_method, ActorAuthMethod::BrowserSession); + } + + #[tokio::test] + async fn saturated_actor_lookup_returns_capacity_error_not_anonymous_actor() { + let store = seeded_store(); + let executor = store.db_executor(); + let (started, started_rx) = tokio::sync::oneshot::channel(); + let (release, release_rx) = std::sync::mpsc::channel(); + let blocker_executor = executor.clone(); + let blocker = tokio::spawn(async move { + blocker_executor + .run(move || -> Result<()> { + let _ = started.send(()); + release_rx.recv().unwrap(); + Ok(()) + }) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(1), started_rx) + .await + .unwrap() + .unwrap(); + let mut queued = Box::pin(executor.run(|| -> Result<()> { Ok(()) })); + assert!(futures::poll!(queued.as_mut()).is_pending()); + let mut headers = HeaderMap::new(); + headers.insert(header::COOKIE, "session=session-secret".parse().unwrap()); + let result = tokio::time::timeout( + std::time::Duration::from_secs(1), + resolve_request_actor(store, &headers, "session"), + ) + .await; + release.send(()).unwrap(); + blocker.await.unwrap().unwrap(); + queued.await.unwrap(); + assert!(matches!( + result.expect("capacity rejection must not wait for queued SQL"), + Err(Error::DbCapacityExceeded) + )); + } #[test] fn user_code_uses_the_uuid_random_tail() { diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 02018b96c..491b30bda 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -1,8 +1,9 @@ //! Projects Workspace Tickets, Objectives, and Memory from domain records to API resources. //! //! Normal Backend reads use Workspace-scoped SQLite/domain authority, not a Ticket backend -//! chosen from Worker cwd or legacy repository file layouts. The ticket crate owns Ticket state, -//! relations, and content; the Workspace store owns assignments and Runtime Worker links. +//! chosen from Worker cwd or legacy repository file layouts. The ticket crate owns case state, +//! content, relations and delegation/handling records; Workspace composes those schemas and +//! owns operational Worker links, work releases and startup/delivery fences. //! Builds bounded detail, thread, relation, assignment, and evidence projections, not full //! execution sessions as substitute records for domain decisions. //! @@ -10,7 +11,10 @@ //! Evidence filters use current projections excluding closed MRs: commit requires a settled source; //! approved_review requires approval tied to the current Ticket content digest and all target MR //! sources/results. Merged MRs use stored integration evidence, so provider outages alone do not -//! invalidate approval. +//! invalidate approval. Evidence is a read-side attestation, never a case-close or handling gate. +//! An MRless Ticket has no applicable MR evidence, not missing review merely because it is closed. +//! Evidence predicates use the full projection after observation; bounded candidate scans can +//! return an empty page with more candidates. Continue its cursor rather than assuming exhaustion. use std::{path::PathBuf, sync::Arc}; @@ -21,11 +25,11 @@ use merge_request::{ MergeRequest, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, ReviewDecision, }; use project_record::{allocate_record_id, unix_epoch_millis_now}; -use rusqlite::{params_from_iter, types::Value as SqlValue}; +use rusqlite::{OptionalExtension, params, params_from_iter, types::Value as SqlValue}; use ticket::{ SqliteTicketBackend, SqliteTicketListCursor, SqliteTicketListItem, SqliteTicketListPageQuery, - TicketBackend, TicketEvent, TicketIdOrSlug, TicketWorkflowState, TicketWorkspaceActionPriority, + TicketBackend, TicketEvent, TicketIdOrSlug, TicketState, TicketWorkspaceActionPriority, project_ticket_workspace_item, }; @@ -252,6 +256,20 @@ impl SqliteWorkspaceAuthority { }) } + /// Assignment self-report enrichment is a separate projection so retained + /// responsibility and existing Ticket constructors stay distinct from status. + pub fn ticket_assignment_tracking( + &self, + reference: &str, + ) -> Result> { + let ticket = self + .ticket_backend + .show(TicketIdOrSlug::from(reference)) + .map_err(Error::from)?; + self.store + .list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id) + } + pub fn with_source_commit_resolver( mut self, source_commit_resolver: Arc, @@ -296,7 +314,7 @@ impl SqliteWorkspaceAuthority { id: ticket.id, resource_key: ticket.resource_key, title: ticket.title, - state: ticket.state, + state: ticket.state.as_str().into(), }) .collect::>(); let resources = self @@ -401,17 +419,82 @@ impl SqliteWorkspaceAuthority { let states = query .states .iter() - .map(|state| bind(SqlValue::Text(state.clone()))) + .map(|state| bind(SqlValue::Text(state.as_str().into()))) .collect::>(); predicates.push(format!("t.workflow_state IN ({})", states.join(","))); } + if let Some(reason) = &query.closure_reason { + let encoded = + serde_json::to_string(reason).map_err(|e| Error::InvalidInput(e.to_string()))?; + let value = bind(SqlValue::Text(encoded)); + predicates.push(format!("t.workflow_state='closed' AND EXISTS (SELECT 1 FROM typed_ticket_case_metadata m JOIN typed_ticket_case_history h ON h.workspace_id=m.workspace_id AND h.ticket_id=m.ticket_id AND h.operation_key=m.closure_operation_key WHERE m.workspace_id=t.workspace_id AND m.ticket_id=t.ticket_id AND json_extract(h.judgment_json,'$.action.reason')=json_extract({value},'$'))")); + } + if query.assigned_principal.is_some() || query.handling_status.is_some() { + let mut assignment = vec![ + "a.workspace_id=t.workspace_id".to_string(), + "a.ticket_id=t.ticket_id".to_string(), + ]; + if let Some(principal) = &query.assigned_principal { + match principal { + ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => { + let value = bind(SqlValue::Text(agent_key.clone())); + assignment.push(format!( + "a.principal_kind='workspace_agent' AND a.principal_id={value}" + )); + } + ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => { + let value = bind(SqlValue::Text(account_id.clone())); + assignment.push(format!( + "a.principal_kind='user' AND a.principal_id={value}" + )); + } + ticket::TicketAssignmentPrincipalSnapshot::Worker { + runtime_id, + worker_id, + } => { + let runtime = bind(SqlValue::Text(runtime_id.clone())); + let worker = bind(SqlValue::Text(worker_id.clone())); + assignment.push(format!("a.principal_kind='worker' AND a.runtime_id={runtime} AND a.worker_id={worker}")); + } + } + } + if let Some(status) = &query.handling_status { + let value = match status { + ticket::TicketAssignmentTrackingStatus::Waiting => "waiting", + ticket::TicketAssignmentTrackingStatus::Inprogress => "inprogress", + ticket::TicketAssignmentTrackingStatus::Done => "done", + }; + let value = bind(SqlValue::Text(value.into())); + assignment.push(format!( + "a.role='orchestrator' AND json_extract(a.tracking_json,'$.status')={value}" + )); + } + predicates.push(format!("EXISTS (SELECT 1 FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.assignment_id=c.assignment_id WHERE {})",assignment.join(" AND "))); + } + for id in &query.tag_ids { + let id = bind(SqlValue::Text(id.clone())); + predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id={id} AND tag.deleted=0)")); + } + for (ids, negate) in [(&query.tag_ids_any, false), (&query.tag_ids_none, true)] { + if !ids.is_empty() { + let ids = ids + .iter() + .map(|id| bind(SqlValue::Text(id.clone()))) + .collect::>() + .join(","); + let not = if negate { "NOT " } else { "" }; + predicates.push(format!("{not}EXISTS (SELECT 1 FROM typed_ticket_tags l JOIN workspace_tags tag ON tag.workspace_id=l.workspace_id AND tag.tag_id=l.tag_id WHERE l.workspace_id=t.workspace_id AND l.ticket_id=t.ticket_id AND l.tag_id IN ({ids}) AND tag.deleted=0)")); + } + } if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) { let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase()))); predicates.push(format!( "(lower(t.title) LIKE {p} OR lower(t.body) LIKE {p} OR EXISTS ( SELECT 1 FROM typed_ticket_events e WHERE e.workspace_id=t.workspace_id AND e.ticket_id=t.ticket_id - AND lower(e.body) LIKE {p}))", + AND lower(e.body) LIKE {p}) OR EXISTS ( + SELECT 1 FROM typed_ticket_case_history h WHERE h.workspace_id=t.workspace_id AND h.ticket_id=t.ticket_id + AND lower(CAST(json_extract(h.judgment_json,'$.action.reason') AS TEXT)) LIKE {p}))", p = pattern )); } @@ -452,14 +535,7 @@ impl SqliteWorkspaceAuthority { WHERE relation.workspace_id=t.workspace_id AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on') OR (relation.target=t.ticket_id AND relation.kind='blocks')) - AND blocker.workflow_state NOT IN ('done','closed'))"; - let active_blocker = "EXISTS (SELECT 1 FROM typed_ticket_relations relation - JOIN typed_tickets blocker ON blocker.workspace_id=relation.workspace_id - AND blocker.ticket_id=CASE WHEN relation.ticket_id=t.ticket_id THEN relation.target ELSE relation.ticket_id END - WHERE relation.workspace_id=t.workspace_id - AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on') - OR (relation.target=t.ticket_id AND relation.kind='blocks')) - AND blocker.workflow_state NOT IN ('queued','inprogress','done','closed'))"; + AND blocker.workflow_state = 'open')"; if !query.event_kinds.is_empty() { let event_kinds = query .event_kinds @@ -473,17 +549,13 @@ impl SqliteWorkspaceAuthority { // a primary-MR/latest-request or implementation-report heuristic loses candidates. for attention in &query.attention { match attention.as_str() { - "done_not_closed" => predicates.push("t.workflow_state='done'".into()), "blocked" => predicates.push(blocker.into()), "unblocked" => predicates.push(format!("NOT {blocker}")), - "ready" => predicates.push(format!("t.workflow_state='ready' AND NOT {blocker}")), _ => {} // live evidence is evaluated after observation } } let rank_expression = match sort { - TicketQuerySort::Priority => format!( - "CASE WHEN t.workflow_state='ready' AND NOT {active_blocker} THEN 0 WHEN t.workflow_state IN ('queued','inprogress') THEN 1 ELSE 2 END" - ), + TicketQuerySort::Priority => "0".to_string(), TicketQuerySort::Relevance => { if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) { let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase()))); @@ -779,12 +851,12 @@ impl SqliteWorkspaceAuthority { }) }) .transpose()?; - // Responsibility remains visible after work ends; action eligibility must - // use only active unfinished work, including after a Ticket is reopened. + // Work start eligibility uses unfinished work. Delegation authoring and + // clearing use the retained current pointer, including after close. let active_role_assignments = self .store .list_active_ticket_role_assignments(&self.workspace_id, id)?; - let has_orchestrator = active_role_assignments + let has_orchestrator = role_assignments .iter() .any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator); let has_worker = active_role_assignments @@ -807,7 +879,6 @@ impl SqliteWorkspaceAuthority { }) .collect::>>()?; let write_repository_keys = ticket_write_target_repository_keys(&targets); - let has_blockers = !ticket.relations.blockers.is_empty(); let mut assignment_diagnostics = Vec::new(); if let Some(legacy_assignee) = ticket .meta @@ -821,25 +892,20 @@ impl SqliteWorkspaceAuthority { } let action_blockers = Vec::new(); let action_eligibility = TicketActionEligibility { - can_assign_orchestrator: matches!( - ticket.meta.workflow_state, - TicketWorkflowState::Planning | TicketWorkflowState::Ready - ) && !has_orchestrator - && !has_worker, - can_unassign_orchestrator: has_orchestrator - && matches!( - ticket.meta.workflow_state, - TicketWorkflowState::Planning | TicketWorkflowState::Ready - ), - can_queue: true, - can_start_manual_worker: matches!( - ticket.meta.workflow_state, - TicketWorkflowState::Planning - | TicketWorkflowState::Ready - | TicketWorkflowState::Queued - | TicketWorkflowState::InProgress - ) && !has_worker - && !has_blockers, + // Case openness is not readiness. These are explicit operations; + // occupancy remains an independent resource constraint. + // Delegation neither occupies nor transfers existing Worker work. + // Start eligibility below separately retains unfinished-work guards. + // A fresh operation explicitly delegates/reissues even to the same + // principal. Expected-assignment CAS (when supplied) and source + // authority are checked by the mutation, not by Ticket phase. + can_assign_orchestrator: true, + can_unassign_orchestrator: has_orchestrator, + can_queue: ticket.meta.workflow_state == TicketState::Open, + // Unfinished dependencies remain relation diagnostics, not a blanket + // start refusal. Backend start/assign still validates authorization, + // resource grants, reservations and actual unfinished work. + can_start_manual_worker: ticket.meta.workflow_state == TicketState::Open && !has_worker, queue_tickets: dependency_check.queue_tickets.clone(), blockers: action_blockers, }; @@ -926,20 +992,29 @@ impl SqliteWorkspaceAuthority { &blocker.blocking_ticket, )?; } + let assignment_tracking = self + .store + .list_current_ticket_assignment_tracking(&self.workspace_id, &ticket.meta.id)?; Ok(TicketDetail { + assignment_tracking, id: ticket.meta.id, resource_key, title: ticket.meta.title, - state: ticket.meta.workflow_state.as_str().to_string(), + state: ticket.meta.workflow_state.into(), readiness: ticket.meta.readiness, priority: ticket.meta.priority, created_at: ticket.meta.created_at, updated_at: ticket.meta.updated_at, content_digest, + closure_operation_key: ticket.closure_operation_key.clone(), + current_closure: ticket.current_closure.clone(), + closure_history: ticket.case_history.clone(), + legacy_handoff: ticket.legacy_workflow_snapshot.clone(), queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, targets, risk_flags: ticket.meta.risk_flags, + tags: ticket.tags, body, body_truncated, event_count: ticket.events.len(), @@ -977,6 +1052,44 @@ impl SqliteWorkspaceAuthority { } } +impl SqliteWorkspaceAuthority { + /// Bounded list projection: read only one immutable closure record and the + /// migration handoff, never the full body or event history for every row. + fn populate_ticket_summary_case_fields(&self, summary: &mut TicketSummary) -> Result<()> { + self.store.with_conn(|conn| { + summary.closure_operation_key = conn.query_row( + "SELECT closure_operation_key FROM typed_ticket_case_metadata WHERE workspace_id=?1 AND ticket_id=?2", + params![self.workspace_id, summary.id], |row| row.get(0) + ).optional()?.flatten(); + if summary.state == server_api::BrowserTicketState::Closed { + if let Some(key) = &summary.closure_operation_key { + let json: Option = conn.query_row( + "SELECT judgment_json FROM typed_ticket_case_history WHERE workspace_id=?1 AND ticket_id=?2 AND operation_key=?3", + params![self.workspace_id, summary.id, key], |row| row.get(0) + ).optional()?; + if let Some(json) = json { + let entry: ticket::TicketCaseHistoryEntry = serde_json::from_str(&json).map_err(|e| Error::Store(e.to_string()))?; + let reason = match entry.action { + ticket::TicketCaseAction::Close { reason } | ticket::TicketCaseAction::CorrectClosure { reason } => reason, + ticket::TicketCaseAction::Reopen => return Err(Error::Store("Closure key names a reopen receipt".into())), + }; + summary.current_closure = Some(ticket::TicketClosureJudgment { + operation_key: entry.operation_key, reason, explanation: entry.explanation, + references: entry.references, author: entry.author, at: entry.at, + }); + } + } + } + let snapshot: Option = conn.query_row( + "SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot WHERE workspace_id=?1 AND ticket_id=?2", + params![self.workspace_id, summary.id], |row| row.get(0) + ).optional()?; + summary.legacy_handoff = snapshot.map(|json| serde_json::from_str(&json).map_err(|e| Error::Store(e.to_string()))).transpose()?; + Ok(()) + }) + } +} + impl TicketAuthority for SqliteWorkspaceAuthority { fn list_tickets(&self, limit: usize) -> Result> { let projection = self.ticket_backend.list_workspace_projection(limit)?; @@ -984,24 +1097,9 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .items .into_iter() .map(|item| { - let projection = - project_ticket_workspace_item(&item.summary, &item.relation_blockers, None); - let resource_key = item.summary.resource_key.clone().ok_or_else(|| { - Error::Store(format!("missing resource key for {}", item.summary.id)) - })?; - Ok::<_, Error>(TicketSummary { - resource_key, - id: item.summary.id, - title: item.summary.title, - state: item.summary.workflow_state.as_str().to_string(), - priority: item.summary.priority, - updated_at: item.summary.updated_at, - queued_by: item.summary.queued_by, - queued_at: item.summary.queued_at, - workspace_action_priority: workspace_action_priority_name(projection.priority) - .to_string(), - record_source: "sqlite_yoi_ticket".to_string(), - }) + let mut summary = ticket_summary_from_sqlite_item(item)?; + self.populate_ticket_summary_case_fields(&mut summary)?; + Ok(summary) }) .collect::>>()?; Ok(ProjectRecordList { @@ -1019,7 +1117,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { let parsed_states = states .iter() .map(|state| { - TicketWorkflowState::parse(state).ok_or_else(|| { + TicketState::parse(state).ok_or_else(|| { Error::InvalidRecordId(format!("unsupported ticket state `{state}`")) }) }) @@ -1043,7 +1141,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority { let items = page .items .into_iter() - .map(ticket_summary_from_sqlite_item) + .map(|item| { + let mut summary = ticket_summary_from_sqlite_item(item)?; + self.populate_ticket_summary_case_fields(&mut summary)?; + Ok(summary) + }) .collect::>>()?; let next_cursor = page .next @@ -1087,6 +1189,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { let summary = ticket_summary_from_ticket(&authoritative)?; let authoritative_body = authoritative.document.body.clone(); let authoritative_events = authoritative.events.clone(); + let authoritative_case_history = authoritative.case_history.clone(); let detail = self.ticket_detail_from_ticket( authoritative, TicketShowRequest { @@ -1100,6 +1203,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { &detail, authoritative_body.as_str(), &authoritative_events, + &authoritative_case_history, &query, ); let item = ticket_query_item( @@ -1107,6 +1211,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { &detail, authoritative_body.as_str(), &authoritative_events, + &authoritative_case_history, &query, ); last_scanned_cursor = Some(make_ticket_cursor(&item, sort, &fingerprint)); @@ -1959,11 +2064,38 @@ fn ticket_evidence_summary( } fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { - for state in &query.states { - if ticket::TicketWorkflowState::parse(state).is_none() { - return Err(Error::InvalidRecordId(format!( - "unsupported Ticket workflow state `{state}`" - ))); + if let Some(ticket::TicketClosureReason::Other(text)) = &query.closure_reason + && (text.trim().is_empty() || text.len() > 1024 || text.contains('\0')) + { + return Err(Error::InvalidInput( + "Closure reason Other must be nonblank and at most 1024 bytes without NUL".into(), + )); + } + if let Some(principal) = &query.assigned_principal { + let ids: Vec<&str> = match principal { + ticket::TicketAssignmentPrincipalSnapshot::User { account_id } => vec![account_id], + ticket::TicketAssignmentPrincipalSnapshot::WorkspaceAgent { agent_key } => { + vec![agent_key] + } + ticket::TicketAssignmentPrincipalSnapshot::Worker { + runtime_id, + worker_id, + } => vec![runtime_id, worker_id], + }; + if ids + .iter() + .any(|id| id.trim().is_empty() || id.len() > 1024 || id.chars().any(char::is_control)) + { + return Err(Error::InvalidInput( + "Assigned principal requires bounded stable identities".into(), + )); + } + } + for ids in [&query.tag_ids, &query.tag_ids_any, &query.tag_ids_none] { + if ids.len() > 100 || ids.iter().any(|id| id.is_empty() || id.len() > 128) { + return Err(Error::InvalidInput( + "tag filters require at most 100 nonempty stable IDs of at most 128 bytes".into(), + )); } } for evidence in &query.evidence { @@ -1976,25 +2108,6 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { ))); } } - for attention in &query.attention { - if !matches!( - attention.as_str(), - "done_not_closed" - | "unresolved_review" - | "missing_commit" - | "blocked" - | "unblocked" - | "ready" - | "awaiting_review" - | "unresolved_changes" - | "stale_after_rescope" - | "missing_evidence" - ) { - return Err(Error::InvalidRecordId(format!( - "unsupported Ticket attention filter `{attention}`" - ))); - } - } if let Some(status) = query.review_status.as_deref() && !matches!( status, @@ -2139,19 +2252,17 @@ fn ticket_evidence_matches(evidence: &TicketEvidenceSummary, filter: &str) -> bo } fn ticket_attention_matches( - state: &str, + _state: &str, evidence: &TicketEvidenceSummary, is_blocked: bool, authoritative_events: &[TicketEvent], attention: &str, ) -> bool { match attention { - "done_not_closed" => state == "done", "unresolved_review" => evidence.unresolved_request_changes, "missing_commit" => !evidence.has_commit, "blocked" => is_blocked, "unblocked" => !is_blocked, - "ready" => state == "ready" && !is_blocked, "awaiting_review" => { evidence.has_review_request && evidence.review_status.as_deref() == Some("pending") } @@ -2168,14 +2279,42 @@ fn ticket_attention_matches( } } +fn closure_reason_text(action: &ticket::TicketCaseAction) -> Option { + let reason = match action { + ticket::TicketCaseAction::Close { reason } + | ticket::TicketCaseAction::CorrectClosure { reason } => reason, + ticket::TicketCaseAction::Reopen => return None, + }; + Some(match reason { + ticket::TicketClosureReason::Completed => "completed".into(), + ticket::TicketClosureReason::NotPlanned => "not_planned".into(), + ticket::TicketClosureReason::Duplicate => "duplicate".into(), + ticket::TicketClosureReason::Unknown => "unknown".into(), + ticket::TicketClosureReason::Other(text) => format!("other {text}"), + }) +} + fn ticket_matches_query( summary: &TicketSummary, detail: &TicketDetail, authoritative_body: &str, authoritative_events: &[TicketEvent], + authoritative_case_history: &[ticket::TicketCaseHistoryEntry], query: &TicketQueryRequest, ) -> bool { - if !query.states.is_empty() && !query.states.iter().any(|state| state == &summary.state) { + let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id); + if !query.tag_ids.iter().all(has_tag) + || (!query.tag_ids_any.is_empty() && !query.tag_ids_any.iter().any(has_tag)) + || query.tag_ids_none.iter().any(has_tag) + { + return false; + } + if !query.states.is_empty() + && !query + .states + .iter() + .any(|state| server_api::BrowserTicketState::from(*state) == summary.state) + { return false; } if query @@ -2223,11 +2362,11 @@ fn ticket_matches_query( } if !query.attention.iter().all(|attention| { ticket_attention_matches( - &summary.state, + summary.state.as_str(), &detail.evidence, !detail.relations.blockers.is_empty(), authoritative_events, - attention, + attention.as_str(), ) }) { return false; @@ -2286,7 +2425,10 @@ fn ticket_matches_query( } query.query.as_ref().is_none_or(|text| { let needle = text.to_lowercase(); - summary.title.to_lowercase().contains(&needle) + authoritative_case_history.iter().any(|entry| { + closure_reason_text(&entry.action) + .is_some_and(|reason| reason.to_lowercase().contains(&needle)) + }) || summary.title.to_lowercase().contains(&needle) || authoritative_body.to_lowercase().contains(&needle) || authoritative_events .iter() @@ -2299,6 +2441,7 @@ fn ticket_query_item( detail: &TicketDetail, authoritative_body: &str, authoritative_events: &[TicketEvent], + authoritative_case_history: &[ticket::TicketCaseHistoryEntry], query: &TicketQueryRequest, ) -> TicketQueryItem { let mut matched_fields = Vec::new(); @@ -2306,6 +2449,14 @@ fn ticket_query_item( let mut matching_event = None; if let Some(text) = query.query.as_ref() { let needle = text.to_lowercase(); + if let Some(reason) = authoritative_case_history + .iter() + .filter_map(|entry| closure_reason_text(&entry.action)) + .find(|reason| reason.to_lowercase().contains(&needle)) + { + matched_fields.push("closure_reason".into()); + snippet = Some(reason); + } if summary.title.to_lowercase().contains(&needle) { matched_fields.push("title".to_string()); snippet = Some(summary.title.clone()); @@ -2328,15 +2479,20 @@ fn ticket_query_item( } } TicketQueryItem { + assignment_tracking: detail.assignment_tracking.clone(), id: summary.id, resource_key: summary.resource_key, title: summary.title, state: summary.state, readiness: detail.readiness.clone(), + tags: detail.tags.clone(), priority: summary.priority, created_at: detail.created_at.clone(), updated_at: summary.updated_at, content_digest: detail.content_digest.clone(), + closure_operation_key: detail.closure_operation_key.clone(), + current_closure: detail.current_closure.clone(), + legacy_handoff: detail.legacy_handoff.clone(), workspace_action_priority: summary.workspace_action_priority, matched_fields, snippet: snippet.map(|value| truncate_body(&value, 512).0), @@ -2689,6 +2845,7 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result kind: ticket.meta.kind.clone(), priority: ticket.meta.priority.clone(), labels: ticket.meta.labels.clone(), + tags: ticket.tags.clone(), readiness: ticket.meta.readiness.clone(), workflow_state: ticket.meta.workflow_state, workflow_state_explicit: ticket.meta.workflow_state_explicit, @@ -2696,10 +2853,14 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result queued_at: ticket.meta.queued_at.clone(), updated_at: ticket.meta.updated_at.clone(), }; - ticket_summary_from_sqlite_item(SqliteTicketListItem { + let mut summary = ticket_summary_from_sqlite_item(SqliteTicketListItem { summary, relation_blockers: ticket.relations.blockers.clone(), - }) + })?; + summary.closure_operation_key = ticket.closure_operation_key.clone(); + summary.current_closure = ticket.current_closure.clone(); + summary.legacy_handoff = ticket.legacy_workflow_snapshot.clone(); + Ok(summary) } fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result { @@ -2713,13 +2874,17 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result SqliteWorkspaceStore { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store.upsert_workspace(&workspace()).await.unwrap(); store } @@ -1454,7 +1454,7 @@ mod tests { #[tokio::test] async fn invalid_candidate_is_never_persisted() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store.upsert_workspace(&workspace()).await.unwrap(); let current = store.load_workspace_config("w-config").unwrap().unwrap(); let main = current.snapshot.get(&path(MAIN_CONFIG_ENTRYPOINT)).unwrap(); @@ -1478,7 +1478,7 @@ mod tests { #[tokio::test] async fn valid_candidate_commits_snapshot_content_and_provenance_atomically() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store.upsert_workspace(&workspace()).await.unwrap(); let current = store.load_workspace_config("w-config").unwrap().unwrap(); let committed = store @@ -1496,7 +1496,7 @@ mod tests { #[tokio::test] async fn stale_cas_cannot_overwrite_newer_tree() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store.upsert_workspace(&workspace()).await.unwrap(); let current = store.load_workspace_config("w-config").unwrap().unwrap(); let request = commit_request(¤t, vec![update_main(¤t, "{ answer = 42; }")]); @@ -1514,7 +1514,7 @@ mod tests { #[tokio::test] async fn committed_content_remains_retrievable_after_later_commit() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store.upsert_workspace(&workspace()).await.unwrap(); let current = store.load_workspace_config("w-config").unwrap().unwrap(); let first = store @@ -1772,7 +1772,7 @@ mod tests { #[test] fn migration_creates_config_authority_tables() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store .with_conn(|conn| { for table in [ diff --git a/crates/workspace-server/src/db_executor.rs b/crates/workspace-server/src/db_executor.rs new file mode 100644 index 000000000..344bf98e6 --- /dev/null +++ b/crates/workspace-server/src/db_executor.rs @@ -0,0 +1,971 @@ +//! Bounded execution of whole synchronous DB operations, independent of Tokio. +//! +//! Accepted jobs belong to the workers, not their async waiters. Cancelling a +//! waiter does not cancel or retry SQL, release capacity, or release its lease. +//! Ownership lasts through discarded-result cleanup and ordered local publication. +//! Synchronous post-commit child submissions inherit the lifecycle source; detached +//! Tokio tasks do not. Panics report unknown outcome even after commit, never retry. +//! +//! Admission is nonblocking and bounds actual jobs as well as queued jobs. Rejection +//! means not accepted; HTTP callers classify capacity/unavailability as 503, not +//! authentication failure or domain conflict. Production stores share one owner; +//! fixtures inject isolated owners whose clones share limits, without global settings. +//! Stop producers before draining and destroying resources; a drain is not queue closure. +//! +//! Move the whole connection/mutex/transaction/commit/publication operation into one +//! owned closure. Never split source checks, receipt comparison, CAS, audit, or results +//! around awaits or add a read pool. Runtime/network waits belong on the independent +//! Host lane, outside SQL transactions, so mandatory DB callbacks can still finish. +use std::{ + collections::BTreeSet, + future::Future, + num::NonZeroUsize, + panic::{AssertUnwindSafe, catch_unwind}, + sync::{Arc, Mutex, OnceLock, mpsc}, +}; +use tokio::sync::{Notify, oneshot}; + +use crate::{Error, Result}; + +/// The default is one FIFO worker and at most 32 waiting operations. +#[derive(Clone, Copy, Debug)] +pub struct DbExecutorLimits { + pub workers: NonZeroUsize, + pub queued: NonZeroUsize, +} + +impl Default for DbExecutorLimits { + fn default() -> Self { + Self { + workers: NonZeroUsize::new(1).unwrap(), + queued: NonZeroUsize::new(32).unwrap(), + } + } +} + +/// A request can retain its lifecycle authority until actual SQL finishes. +/// Retention happens at submission, while the caller's task-local scope exists. +pub(crate) trait DbOperationLifetime: Send + Sync { + fn retain(&self) -> Box; +} + +tokio::task_local! { + static OPERATION_LIFETIME: Arc; +} + +pub(crate) async fn with_operation_lifetime( + lifetime: Arc, + future: F, +) -> F::Output { + OPERATION_LIFETIME.scope(lifetime, future).await +} + +#[derive(Default)] +struct Accepted { + last_job_id: u64, + unfinished: BTreeSet, +} + +#[derive(Default)] +struct Tracking { + accepted: Mutex, + finished: Notify, +} + +struct Completion { + tracking: Arc, + id: u64, +} + +impl Drop for Completion { + fn drop(&mut self) { + self.tracking + .accepted + .lock() + .unwrap() + .unfinished + .remove(&self.id); + self.tracking.finished.notify_waiters(); + } +} + +struct Job { + operation: Box, + // Field drop order releases authority before completion becomes observable. + _lifetime: Option>, + lifetime_source: Option>, + _completion: Completion, + runtime: Option, + diagnostics: tracing::Dispatch, + span: tracing::Span, +} + +fn report_unknown(accepted_job_id: u64, phase: &'static str) { + tracing::error!( + code = "db_operation_outcome_unknown", + accepted_job_id, + phase, + error = %Error::DbOperationOutcomeUnknown, + "DB job panicked; outcome unknown, operation will not be retried" + ); +} + +/// Clones share the queue and actual-job budget. Workers retain only the +/// receiver, so dropping the last sender closes the queue; accepted jobs finish +/// before the dedicated threads exit. No thread is created per store open. +#[derive(Clone)] +pub struct DbExecutor { + sender: Option>, + tracking: Arc, + max_jobs: usize, +} + +impl DbExecutor { + pub fn new(limits: DbExecutorLimits) -> Result { + let max_jobs = limits + .workers + .get() + .checked_add(limits.queued.get()) + .ok_or_else(|| Error::InvalidInput("DB executor limits overflow".into()))?; + let (sender, receiver) = mpsc::sync_channel::(limits.queued.get()); + let receiver = Arc::new(Mutex::new(receiver)); + for index in 0..limits.workers.get() { + let receiver = receiver.clone(); + std::thread::Builder::new() + .name(format!("workspace-db-{index}")) + .spawn(move || { + loop { + let next = receiver.lock().unwrap().recv(); + let Ok(job) = next else { break }; + let Job { + operation, + _lifetime: lifetime, + lifetime_source, + _completion: completion, + runtime, + diagnostics, + span, + } = job; + // Keep diagnostics with their submitting subscriber as + // well as the runtime; no process-global override. + let _diagnostics = tracing::dispatcher::set_default(&diagnostics); + let _span = span.enter(); + // Preserve the submitting runtime for synchronous Host + // post-commit hooks, including reply/lease destructors. + // enter() supplies context only: never drive a future + // or await/nest an executor submission in a transaction. + let _entered = runtime.as_ref().map(tokio::runtime::Handle::enter); + // A cancelled waiter's discarded result may itself have + // a destructor. Keep both the lease and actual-job slot + // until that cleanup finishes, even if it panics. + let execute = || { + if catch_unwind(AssertUnwindSafe(operation)).is_err() { + report_unknown(completion.id, "reply_cleanup"); + } + if catch_unwind(AssertUnwindSafe(|| drop(lifetime))).is_err() { + report_unknown(completion.id, "lifetime_cleanup"); + } + }; + // Synchronous post-commit continuations can admit another + // bounded job. Transfer the original lifecycle source as + // well, so its authority survives the parent job/waiter. + // This does not inject context into detached Tokio tasks. + if let Some(source) = lifetime_source { + OPERATION_LIFETIME.sync_scope(source, execute); + } else { + execute(); + } + drop(completion); + } + }) + .map_err(|_| Error::DbExecutorUnavailable)?; + } + Ok(Self { + sender: Some(sender), + tracking: Arc::default(), + max_jobs, + }) + } + + pub fn shared() -> Self { + static SHARED: OnceLock = OnceLock::new(); + SHARED + .get_or_init(|| { + Self::new(DbExecutorLimits::default()).unwrap_or_else(|_| Self { + sender: None, + tracking: Arc::default(), + max_jobs: 0, + }) + }) + .clone() + } + + /// Submit without waiting for queue capacity. The closure must contain the + /// entire synchronous operation/transaction, with no nested async runtime. + /// The submitting Tokio handle, when present, is entered for synchronous + /// post-commit hooks and worker cleanup; it is never used to block_on SQL. + pub async fn run( + &self, + operation: impl FnOnce() -> std::result::Result + Send + 'static, + ) -> std::result::Result + where + T: Send + 'static, + E: From + Send + 'static, + { + self.submit(operation)? + .await + .unwrap_or_else(|_| Err(Error::DbOperationOutcomeUnknown.into())) + } + + /// Admit a fire-and-forget operation immediately, without spawning a Tokio + /// task or waiting for capacity. Err means the operation was not accepted; + /// Ok means the worker owns it through execution and cleanup, even after + /// the submitting task is cancelled. A panic emits a structured unknown- + /// outcome diagnostic, never an automatic retry. drain() tracks these jobs. + pub fn dispatch(&self, operation: impl FnOnce() + Send + 'static) -> Result<()> { + self.submit(move || { + operation(); + Ok::<_, Error>(()) + }) + .map(drop) + } + + /// Shared synchronous admission: neither dropped receivers nor cancelled + /// callers remove accepted work from the queue or the actual-job budget. + fn submit( + &self, + operation: impl FnOnce() -> std::result::Result + Send + 'static, + ) -> std::result::Result>, E> + where + T: Send + 'static, + E: From + Send + 'static, + { + let sender = self + .sender + .as_ref() + .ok_or_else(|| E::from(Error::DbExecutorUnavailable))?; + let runtime = tokio::runtime::Handle::try_current().ok(); + let diagnostics = tracing::dispatcher::get_default(Clone::clone); + let span = tracing::Span::current(); + let lifetime_source = OPERATION_LIFETIME.try_with(Clone::clone).ok(); + let lifetime = lifetime_source.as_ref().map(|source| source.retain()); + let (reply, result) = oneshot::channel(); + // Serialize admission and acceptance snapshots. Worker completion cannot + // race acceptance, and cancelled waiters never alter this actual-job cap. + { + let mut accepted = self.tracking.accepted.lock().unwrap(); + if accepted.unfinished.len() >= self.max_jobs { + return Err(Error::DbCapacityExceeded.into()); + } + let id = accepted + .last_job_id + .checked_add(1) + .ok_or_else(|| E::from(Error::DbExecutorUnavailable))?; + // Never reuse the identity of a rejected job: its destructor may run + // after another submission has acquired the admission lock. + accepted.last_job_id = id; + let job = Job { + operation: Box::new(move || { + let outcome = catch_unwind(AssertUnwindSafe(operation)).unwrap_or_else(|_| { + report_unknown(id, "operation"); + Err(Error::DbOperationOutcomeUnknown.into()) + }); + let _ = reply.send(outcome); + }), + _lifetime: lifetime, + lifetime_source, + runtime, + diagnostics, + span, + _completion: Completion { + tracking: self.tracking.clone(), + id, + }, + }; + match sender.try_send(job) { + Ok(()) => { + accepted.unfinished.insert(id); + } + Err(error) => { + // The rejected Job's completion also locks tracking; drop it only + // after releasing the admission lock. + drop(accepted); + return Err(match error { + mpsc::TrySendError::Full(_) => Error::DbCapacityExceeded, + mpsc::TrySendError::Disconnected(_) => Error::DbExecutorUnavailable, + } + .into()); + } + } + } + Ok(result) + } + + /// Wait for every job accepted before this barrier is first polled (not + /// later submissions). This bypasses queue capacity and survives cancelled + /// job waiters. It does not close the queue or stop new submissions. + pub async fn drain(&self) -> Result<()> { + if self.sender.is_none() { + return Err(Error::DbExecutorUnavailable); + } + let through = self.tracking.accepted.lock().unwrap().last_job_id; + loop { + let notified = self.tracking.finished.notified(); + tokio::pin!(notified); + notified.as_mut().enable(); + if self + .tracking + .accepted + .lock() + .unwrap() + .unfinished + .first() + .is_none_or(|id| *id > through) + { + return Ok(()); + } + notified.await; + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use rusqlite::{Connection, TransactionBehavior, ffi}; + use std::{ffi::c_void, time::Duration}; + + fn executor(queued: usize) -> DbExecutor { + DbExecutor::new(DbExecutorLimits { + workers: NonZeroUsize::new(1).unwrap(), + queued: NonZeroUsize::new(queued).unwrap(), + }) + .unwrap() + } + + fn database() -> (tempfile::TempDir, Connection, Connection) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("jobs.sqlite"); + let owner = Connection::open(&path).unwrap(); + owner + .execute_batch("CREATE TABLE writes (value INTEGER NOT NULL)") + .unwrap(); + let worker = Connection::open(&path).unwrap(); + (dir, owner, worker) + } + + struct BusyGate { + waiting: Option>, + release: mpsc::Receiver<()>, + } + + // A fixture-owned callback proves SQLite actually reached its lock wait. + // Unlike busy_handler's fn pointer API, the SQLite API carries a per-call + // context, so tests need neither process-global state nor guessed sleeps. + struct BusyRegistration { + db: *mut ffi::sqlite3, + _gate: Box, + } + + impl BusyRegistration { + fn install(conn: &Connection, gate: BusyGate) -> Self { + unsafe extern "C" fn wait(context: *mut c_void, _: i32) -> i32 { + // The Box remains registered only while this connection and + // BusyRegistration are alive, on this same dedicated worker. + let gate = unsafe { &mut *context.cast::() }; + if let Some(waiting) = gate.waiting.take() { + let _ = waiting.send(()); + if gate.release.recv().is_ok() { + return 1; + } + } + 0 + } + let mut gate = Box::new(gate); + // SAFETY: conn owns the handle; registration is dropped before conn. + let db = unsafe { conn.handle() }; + let result = unsafe { + ffi::sqlite3_busy_handler(db, Some(wait), (&mut *gate as *mut BusyGate).cast()) + }; + assert_eq!(result, ffi::SQLITE_OK); + Self { db, _gate: gate } + } + } + + impl Drop for BusyRegistration { + fn drop(&mut self) { + // SAFETY: unregister before releasing the callback's boxed context. + unsafe { + ffi::sqlite3_busy_handler(self.db, None, std::ptr::null_mut()); + } + } + } + + async fn observed(future: impl Future) -> T { + tokio::time::timeout(Duration::from_secs(5), future) + .await + .expect("DB job did not reach the expected observable state") + } + + #[tokio::test(flavor = "current_thread")] + async fn sqlite_lock_wait_preserves_timer_and_independent_control_progress() { + let (_dir, mut owner, mut worker) = database(); + let lock = owner + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + let executor = executor(1); + let (waiting, blocked) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + let mut write = Box::pin(executor.run(move || -> Result<()> { + let _busy = BusyRegistration::install( + &worker, + BusyGate { + waiting: Some(waiting), + release: permit, + }, + ); + let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("INSERT INTO writes VALUES (1)", [])?; + tx.commit()?; + Ok(()) + })); + assert!(futures::poll!(&mut write).is_pending()); + observed(blocked).await.unwrap(); + let control = tokio::spawn(async { 42 }); + assert_eq!(observed(control).await.unwrap(), 42); + // The timer itself is the contract under test, not synchronization. + observed(tokio::time::sleep(Duration::from_millis(1))).await; + assert!(futures::poll!(&mut write).is_pending()); + lock.rollback().unwrap(); + release.send(()).unwrap(); + observed(write).await.unwrap(); + executor.drain().await.unwrap(); + assert_eq!( + owner + .query_row("SELECT count(*) FROM writes", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + } + + struct Lease(Arc); + struct Retained(Arc); + impl DbOperationLifetime for Lease { + fn retain(&self) -> Box { + self.0.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Box::new(Retained(self.0.clone())) + } + } + impl Drop for Retained { + fn drop(&mut self) { + self.0.fetch_sub(1, std::sync::atomic::Ordering::SeqCst); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn cancelled_queued_mutation_retains_capacity_and_lease_until_sql_commits() { + let (_dir, mut owner, mut worker) = database(); + let lock = owner + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + let executor = executor(1); + let (waiting, blocked) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + let mut running = Box::pin(executor.run(move || -> Result<()> { + let _busy = BusyRegistration::install( + &worker, + BusyGate { + waiting: Some(waiting), + release: permit, + }, + ); + let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("INSERT INTO writes VALUES (1)", [])?; + tx.commit()?; + Ok(()) + })); + assert!(futures::poll!(&mut running).is_pending()); + observed(blocked).await.unwrap(); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let lease: Arc = Arc::new(Lease(count.clone())); + let mut queued_conn = Connection::open(_dir.path().join("jobs.sqlite")).unwrap(); + let mut queued = Box::pin(with_operation_lifetime( + lease, + executor.run(move || -> Result<()> { + let tx = queued_conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + // FIFO is observable in durable SQL, not inferred from waiter order. + let first: i64 = + tx.query_row("SELECT count(*) FROM writes WHERE value = 1", [], |row| { + row.get(0) + })?; + assert_eq!(first, 1); + tx.execute("INSERT INTO writes VALUES (2)", [])?; + tx.commit()?; + Ok(()) + }), + )); + assert!(futures::poll!(&mut queued).is_pending()); + drop(queued); + assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1); + assert!(matches!( + executor.run(|| Ok::<_, Error>(())).await, + Err(Error::DbCapacityExceeded) + )); + let mut drained = Box::pin(executor.drain()); + assert!(futures::poll!(&mut drained).is_pending()); + lock.rollback().unwrap(); + release.send(()).unwrap(); + observed(running).await.unwrap(); + observed(drained).await.unwrap(); + assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + owner + .query_row("SELECT count(*) FROM writes", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 2 + ); + executor.run(|| Ok::<_, Error>(())).await.unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn post_commit_child_job_retains_admitted_lifetime_after_parent_waiter_and_job_finish() { + let (dir, owner, mut parent_conn) = database(); + let mut child_conn = Connection::open(dir.path().join("jobs.sqlite")).unwrap(); + let parent = executor(1); + let child = executor(1); + let child_owner = child.clone(); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let source: Arc = Arc::new(Lease(count.clone())); + let (entered, running) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + let (child_entered, child_running) = oneshot::channel(); + let (finish_child, child_permit) = mpsc::channel(); + let mut operation = Box::pin(with_operation_lifetime( + source, + parent.run(move || -> Result<()> { + let _ = entered.send(()); + let _ = permit.recv(); + let tx = parent_conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("INSERT INTO writes VALUES (1)", [])?; + tx.commit()?; + child_owner.dispatch(move || { + let tx = child_conn + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + let committed: i64 = tx + .query_row("SELECT count(*) FROM writes", [], |row| row.get(0)) + .unwrap(); + assert_eq!(committed, 1, "child must see its parent's committed SQL"); + let _ = child_entered.send(()); + let _ = child_permit.recv(); + tx.execute("INSERT INTO writes VALUES (2)", []).unwrap(); + tx.commit().unwrap(); + })?; + Ok(()) + }), + )); + assert!(futures::poll!(&mut operation).is_pending()); + observed(running).await.unwrap(); + drop(operation); + release.send(()).unwrap(); + observed(child_running).await.unwrap(); + observed(parent.drain()).await.unwrap(); + assert_eq!( + count.load(std::sync::atomic::Ordering::SeqCst), + 1, + "accepted continuation lost authority when its parent finished" + ); + let mut drained = Box::pin(child.drain()); + assert!(futures::poll!(&mut drained).is_pending()); + finish_child.send(()).unwrap(); + observed(drained).await.unwrap(); + assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + owner + .query_row("SELECT count(*) FROM writes", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 2 + ); + } + + #[tokio::test] + async fn failed_transaction_rolls_back_and_releases_actual_job_capacity() { + let (_dir, owner, mut worker) = database(); + let executor = executor(1); + let error = executor + .run(move || -> Result<()> { + let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("INSERT INTO writes VALUES (1)", [])?; + tx.execute("INSERT INTO writes VALUES (NULL)", [])?; + tx.commit()?; + Ok(()) + }) + .await + .unwrap_err(); + assert!(matches!(error, Error::Sqlite(_))); + executor.drain().await.unwrap(); + assert_eq!( + owner + .query_row("SELECT count(*) FROM writes", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + executor.run(|| Ok::<_, Error>(())).await.unwrap(); + } + + #[tokio::test] + async fn panic_after_commit_reports_unknown_without_retry_and_worker_remains_available() { + let (_dir, owner, mut worker) = database(); + let executor = executor(1); + let error = executor + .run(move || -> Result<()> { + let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("INSERT INTO writes VALUES (1)", [])?; + tx.commit()?; + panic!("lost outcome after commit"); + }) + .await + .unwrap_err(); + assert!(matches!(error, Error::DbOperationOutcomeUnknown)); + executor.drain().await.unwrap(); + assert_eq!( + owner + .query_row("SELECT count(*) FROM writes", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!(executor.run(|| Ok::<_, Error>(42)).await.unwrap(), 42); + } + + #[tokio::test] + async fn panic_before_commit_rolls_back_and_releases_lease_and_capacity() { + let (_dir, owner, mut worker) = database(); + let executor = executor(1); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let lease: Arc = Arc::new(Lease(count.clone())); + let error = with_operation_lifetime( + lease, + executor.run(move || -> Result<()> { + let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("INSERT INTO writes VALUES (1)", [])?; + panic!("lost outcome before commit"); + }), + ) + .await + .unwrap_err(); + assert!(matches!(error, Error::DbOperationOutcomeUnknown)); + executor.drain().await.unwrap(); + assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!( + owner + .query_row("SELECT count(*) FROM writes", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 0 + ); + executor.run(|| Ok::<_, Error>(())).await.unwrap(); + } + + struct DiscardedReplyHook { + expected_runtime: tokio::runtime::Id, + lease_count: Arc, + dropped: Option>, + } + + impl Drop for DiscardedReplyHook { + fn drop(&mut self) { + let runtime = + tokio::runtime::Handle::try_current().expect("reply cleanup lost runtime context"); + let retained = self.lease_count.load(std::sync::atomic::Ordering::SeqCst); + let same_runtime = runtime.id() == self.expected_runtime; + let dropped = self.dropped.take().unwrap(); + tokio::spawn(async move { + let _ = dropped.send((same_runtime, retained)); + }); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn cancelled_queued_sql_keeps_runtime_context_and_lease_through_post_commit_and_reply_drop() + { + let (_dir, owner, mut worker) = database(); + let executor = executor(1); + let (started, running) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + let mut blocker = Box::pin(executor.run(move || -> Result<()> { + let _ = started.send(()); + let _ = permit.recv(); + Ok(()) + })); + assert!(futures::poll!(&mut blocker).is_pending()); + observed(running).await.unwrap(); + let expected_runtime = tokio::runtime::Handle::current().id(); + let (hook, published) = oneshot::channel(); + let (finish, finishing) = mpsc::channel(); + let (dropped, reply_dropped) = oneshot::channel(); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let lease: Arc = Arc::new(Lease(count.clone())); + let operation_count = count.clone(); + let mut queued = Box::pin(with_operation_lifetime( + lease, + executor.run(move || -> Result<_> { + let tx = worker.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("INSERT INTO writes VALUES (1)", [])?; + tx.commit()?; + // Model synchronous Host hooks: publish only after commit, checking + // try_current and spawning without driving any future on this worker. + let runtime = tokio::runtime::Handle::try_current() + .expect("post-commit hook lost runtime context"); + assert_eq!(runtime.id(), expected_runtime); + tokio::spawn(async move { + let _ = hook.send(()); + }); + let _ = finishing.recv(); + Ok(DiscardedReplyHook { + expected_runtime, + lease_count: operation_count, + dropped: Some(dropped), + }) + }), + )); + assert!(futures::poll!(&mut queued).is_pending()); + drop(queued); + release.send(()).unwrap(); + observed(blocker).await.unwrap(); + observed(published).await.unwrap(); + assert_eq!( + owner + .query_row("SELECT count(*) FROM writes", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1); + let mut drain = Box::pin(executor.drain()); + assert!(futures::poll!(&mut drain).is_pending()); + finish.send(()).unwrap(); + assert_eq!(observed(reply_dropped).await.unwrap(), (true, 1)); + observed(drain).await.unwrap(); + assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0); + executor.run(|| Ok::<_, Error>(())).await.unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn multiple_workers_bound_actual_jobs_even_after_all_waiters_are_cancelled() { + let executor = DbExecutor::new(DbExecutorLimits { + workers: NonZeroUsize::new(2).unwrap(), + queued: NonZeroUsize::new(1).unwrap(), + }) + .unwrap(); + let mut releases = Vec::new(); + // Observe each worker executing before submitting the next operation, + // so queue occupancy and overload are deterministic, not timing guesses. + for _ in 0..2 { + let (started, running) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + let mut operation = Box::pin(executor.run(move || -> Result<()> { + let _ = started.send(()); + let _ = permit.recv(); + Ok(()) + })); + assert!(futures::poll!(&mut operation).is_pending()); + observed(running).await.unwrap(); + drop(operation); + releases.push(release); + } + let (finished, mut queued_finished) = oneshot::channel(); + let mut queued = Box::pin(executor.run(move || -> Result<()> { + let _ = finished.send(()); + Ok(()) + })); + assert!(futures::poll!(&mut queued).is_pending()); + drop(queued); + let rejected_runs = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + for _ in 0..8 { + let rejected_runs = rejected_runs.clone(); + assert!(matches!( + executor + .run(move || { + rejected_runs.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + Ok::<_, Error>(()) + }) + .await, + Err(Error::DbCapacityExceeded) + )); + } + assert_eq!(rejected_runs.load(std::sync::atomic::Ordering::SeqCst), 0); + assert!(matches!( + queued_finished.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + )); + let mut drain = Box::pin(executor.drain()); + assert!(futures::poll!(&mut drain).is_pending()); + for release in releases { + release.send(()).unwrap(); + } + observed(drain).await.unwrap(); + observed(queued_finished).await.unwrap(); + executor.run(|| Ok::<_, Error>(())).await.unwrap(); + } + + #[derive(Default)] + struct UnknownDiagnostic { + code: Option, + phase: Option, + accepted_job_id: Option, + } + + impl tracing::field::Visit for UnknownDiagnostic { + fn record_str(&mut self, field: &tracing::field::Field, value: &str) { + match field.name() { + "code" => self.code = Some(value.to_owned()), + "phase" => self.phase = Some(value.to_owned()), + _ => {} + } + } + fn record_u64(&mut self, field: &tracing::field::Field, value: u64) { + if field.name() == "accepted_job_id" { + self.accepted_job_id = Some(value); + } + } + fn record_debug(&mut self, _: &tracing::field::Field, _: &dyn std::fmt::Debug) {} + } + + #[derive(Clone, Default)] + struct DiagnosticCapture(Arc>>); + + impl tracing_subscriber::Layer for DiagnosticCapture { + fn on_event( + &self, + event: &tracing::Event<'_>, + _: tracing_subscriber::layer::Context<'_, S>, + ) { + let mut diagnostic = UnknownDiagnostic::default(); + event.record(&mut diagnostic); + self.0.lock().unwrap().push(diagnostic); + } + } + + struct DispatchCleanup { + expected_runtime: tokio::runtime::Id, + lease_count: Arc, + started: Option>, + release: mpsc::Receiver<()>, + } + + impl Drop for DispatchCleanup { + fn drop(&mut self) { + // No panicking assertions during unwind: report observations to the + // test, and gate cleanup so drain/lifetime behavior is observable. + let same_runtime = tokio::runtime::Handle::try_current() + .is_ok_and(|runtime| runtime.id() == self.expected_runtime); + let retained = self.lease_count.load(std::sync::atomic::Ordering::SeqCst); + if let Some(started) = self.started.take() { + let _ = started.send((same_runtime, retained)); + } + let _ = self.release.recv(); + } + } + + #[tokio::test(flavor = "current_thread")] + async fn dispatched_panic_is_bounded_retained_through_cleanup_diagnosed_and_drained_without_retry() + { + use tracing_subscriber::layer::SubscriberExt; + let (_dir, owner, mut worker) = database(); + let executor = executor(1); + let (started, running) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + let mut blocker = Box::pin(executor.run(move || -> Result<()> { + let _ = started.send(()); + let _ = permit.recv(); + Ok(()) + })); + assert!(futures::poll!(&mut blocker).is_pending()); + observed(running).await.unwrap(); + drop(blocker); // Cancellation does not free its running slot. + let expected_runtime = tokio::runtime::Handle::current().id(); + let count = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let lease: Arc = Arc::new(Lease(count.clone())); + let operation_count = count.clone(); + let attempts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let operation_attempts = attempts.clone(); + let (cleanup_started, cleaning_up) = oneshot::channel(); + let (finish, finishing) = mpsc::channel(); + // Model multiple tracing contexts without setting a global subscriber. + // This also prevents tracing's single-subscriber callsite optimization + // from caching an unsubscribed parallel test's Interest::never here. + let _other_diagnostics = tracing::Dispatch::new(tracing_subscriber::registry()); + let captured = DiagnosticCapture::default(); + let diagnostics = + tracing::Dispatch::new(tracing_subscriber::registry().with(captured.clone())); + with_operation_lifetime(lease, async { + tracing::dispatcher::with_default(&diagnostics, || { + executor.dispatch(move || { + let _cleanup = DispatchCleanup { + expected_runtime, + lease_count: operation_count, + started: Some(cleanup_started), + release: finishing, + }; + operation_attempts.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let tx = worker + .transaction_with_behavior(TransactionBehavior::Immediate) + .unwrap(); + tx.execute("INSERT INTO writes VALUES (1)", []).unwrap(); + tx.commit().unwrap(); + panic!("dispatched post-commit outcome lost"); + }) + }) + }) + .await + .unwrap(); // Dispatch is admitted synchronously; no waiter survives. + assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 1); + let rejected_runs = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let rejected_operation = rejected_runs.clone(); + assert!(matches!( + executor.dispatch(move || { + rejected_operation.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + }), + Err(Error::DbCapacityExceeded) + )); + release.send(()).unwrap(); + assert_eq!(observed(cleaning_up).await.unwrap(), (true, 1)); + assert_eq!( + owner + .query_row("SELECT count(*) FROM writes", [], |row| row + .get::<_, i64>(0)) + .unwrap(), + 1 + ); + let mut drain = Box::pin(executor.drain()); + assert!(futures::poll!(&mut drain).is_pending()); + finish.send(()).unwrap(); + observed(drain).await.unwrap(); + assert_eq!(count.load(std::sync::atomic::Ordering::SeqCst), 0); + assert_eq!(attempts.load(std::sync::atomic::Ordering::SeqCst), 1); + assert_eq!(rejected_runs.load(std::sync::atomic::Ordering::SeqCst), 0); + { + let events = captured.0.lock().unwrap(); + assert_eq!(events.len(), 1); + assert_eq!( + events[0].code.as_deref(), + Some("db_operation_outcome_unknown") + ); + assert_eq!(events[0].phase.as_deref(), Some("operation")); + assert!(events[0].accepted_job_id.is_some()); + } + let (completed, completion) = oneshot::channel(); + executor + .dispatch(move || { + let _ = completed.send(()); + }) + .unwrap(); + observed(executor.drain()).await.unwrap(); + observed(completion).await.unwrap(); + } +} diff --git a/crates/workspace-server/src/frozen_content_state_tests.rs b/crates/workspace-server/src/frozen_content_state_tests.rs index 138b83683..954d42ccf 100644 --- a/crates/workspace-server/src/frozen_content_state_tests.rs +++ b/crates/workspace-server/src/frozen_content_state_tests.rs @@ -443,6 +443,7 @@ fn content_cutover_reserved_only_releases_resource_but_uncertain_dispatch_keeps_ migrate_content_state_v85_to_v86(&conn).unwrap(); drop(conn); let store = SqliteWorkspaceStore { + db_executor: crate::db_executor::DbExecutor::shared(), conn: Arc::new(Mutex::new(Connection::open(path).unwrap())), }; let job = store @@ -491,6 +492,7 @@ fn content_cutover_completed_result_cleanup_and_replay_remain_unchanged() { migrate_content_state_v85_to_v86(&conn).unwrap(); drop(conn); let store = SqliteWorkspaceStore { + db_executor: crate::db_executor::DbExecutor::shared(), conn: Arc::new(Mutex::new(Connection::open(path).unwrap())), }; let job = store diff --git a/crates/workspace-server/src/frozen_repository_key_tests.rs b/crates/workspace-server/src/frozen_repository_key_tests.rs index adeef98fe..798f1575d 100644 --- a/crates/workspace-server/src/frozen_repository_key_tests.rs +++ b/crates/workspace-server/src/frozen_repository_key_tests.rs @@ -469,6 +469,7 @@ fn migrated_workdir_request_replays_and_retries_without_accepting_changed_displa // The service consumes current receipt columns; unrelated schemas are not this fixture. rename_saved_secret_receipt_counters(&conn).unwrap(); let store = SqliteWorkspaceStore { + db_executor: crate::db_executor::DbExecutor::shared(), conn: Arc::new(Mutex::new(conn)), }; let migrated = store @@ -652,6 +653,7 @@ fn deleted_successful_workdir_archives_verbatim_history_without_authorizing_retr // The service consumes current receipt columns; unrelated schemas are not this fixture. rename_saved_secret_receipt_counters(&conn).unwrap(); let store = Arc::new(SqliteWorkspaceStore { + db_executor: crate::db_executor::DbExecutor::shared(), conn: Arc::new(Mutex::new(conn)), }); let archived = store @@ -1027,6 +1029,7 @@ fn migrated_ssh_mutations_replay_complete_old_input_without_reexecution_or_recei // The service consumes current receipt columns; unrelated schemas are not this fixture. rename_saved_secret_receipt_counters(&conn).unwrap(); let store = Arc::new(SqliteWorkspaceStore { + db_executor: crate::db_executor::DbExecutor::shared(), conn: Arc::new(Mutex::new(conn)), }); let service = @@ -1179,6 +1182,7 @@ fn deleted_legacy_receipt_does_not_bind_to_later_reused_key_ordinal() { // The service consumes current receipt columns; unrelated schemas are not this fixture. rename_saved_secret_receipt_counters(&conn).unwrap(); let store = Arc::new(SqliteWorkspaceStore { + db_executor: crate::db_executor::DbExecutor::shared(), conn: Arc::new(Mutex::new(conn)), }); let service = diff --git a/crates/workspace-server/src/frozen_runtime_binding_tests.rs b/crates/workspace-server/src/frozen_runtime_binding_tests.rs index fcf7dcc97..9c8bef71f 100644 --- a/crates/workspace-server/src/frozen_runtime_binding_tests.rs +++ b/crates/workspace-server/src/frozen_runtime_binding_tests.rs @@ -533,6 +533,7 @@ mod existing_workspace_provisioning { // This fixture contains only the schema-87 cutover authorities, not the // unrelated full Server schema. Exercise the real service/store methods. Arc::new(SqliteWorkspaceStore { + db_executor: crate::db_executor::DbExecutor::shared(), conn: Arc::new(Mutex::new(conn)), }) } @@ -644,6 +645,7 @@ mod existing_workspace_provisioning { drop(service); drop(store); let store = Arc::new(SqliteWorkspaceStore { + db_executor: crate::db_executor::DbExecutor::shared(), conn: Arc::new(Mutex::new(Connection::open(&path).unwrap())), }); let service = WorkspaceSigningIdentityService::new( @@ -816,7 +818,7 @@ mod current_authority { use super::*; fn binding_store() -> (SqliteWorkspaceStore, WorkspaceRuntimeBinding) { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); let runtime_key = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-key").unwrap(); store.with_conn(|conn| { diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index 7b3641255..b7924dbe1 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -37,7 +37,10 @@ use std::{ }; use workdir::{ Workdir, WorkdirError, WorkdirSessionHandle, - http::{OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization}, + http::{ + OpenWorkdirSessionRequest, RemoteWorkdirSession, WorkdirHttpAuthorization, + WorkdirHttpAuthorizationFuture, + }, }; use worker_runtime::RuntimeWorkspaceScope; use worker_runtime::catalog::{ @@ -464,6 +467,10 @@ pub struct WorkerSpawnWorkingDirectoryRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(deny_unknown_fields)] pub struct WorkerTicketAssignmentRequest { + /// Observed delegation required for Orchestrator Ticket starts; manual starts omit it. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub orchestrator_assignment_id: Option, + pub ticket_id: String, pub operation_id: String, } @@ -1300,11 +1307,13 @@ pub trait WorkspaceWorkerRuntime: Send + Sync { }) } + /// Absence means unsupported observation, not failed authorization. Live binding/signing + /// and bounded admission failures propagate explicitly, never as None or bearer fallback. fn observation_source( &self, _worker_id: &str, - ) -> Option { - None + ) -> Result, RuntimeDiagnostic> { + Ok(None) } fn send_input(&self, worker_id: &str, _request: WorkerInputRequest) -> WorkerInputResult { @@ -2242,6 +2251,11 @@ impl RuntimeRegistry { let runtime = self.runtime(runtime_id)?; runtime .observation_source(worker_id) + .map_err(|diagnostic| RuntimeRegistryError::RuntimeOperationFailed { + runtime_id: runtime_id.to_owned(), + code: diagnostic.code, + message: diagnostic.message, + })? .ok_or_else(|| RuntimeRegistryError::UnknownWorker { worker: worker.clone(), }) @@ -3209,17 +3223,21 @@ impl WorkspaceWorkerRuntime for EmbeddedWorkerRuntime { fn observation_source( &self, worker_id: &str, - ) -> Option { - let worker_ref = self.worker_ref(worker_id)?; + ) -> Result, RuntimeDiagnostic> { + let Some(worker_ref) = self.worker_ref(worker_id) else { + return Ok(None); + }; if self.runtime.worker_detail(&worker_ref).is_err() { - return None; + return Ok(None); } - Some(crate::observation::RuntimeObservationSource::embedded( - crate::observation::EmbeddedRuntimeObservationSource { - worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id), - runtime: self.runtime.clone(), - worker_ref, - }, + Ok(Some( + crate::observation::RuntimeObservationSource::embedded( + crate::observation::EmbeddedRuntimeObservationSource { + worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id), + runtime: self.runtime.clone(), + worker_ref, + }, + ), )) } @@ -3485,6 +3503,41 @@ impl WorkspaceRuntimeAuthorization { } } + /// Offload the complete live-binding check and request signing operation. + /// Claims are minted inside the bounded job, not cached before admission. Async subscription + /// and Workdir callbacks await this stage before network sends; stale/unavailable authority + /// must not fall back to a configured bearer. Synchronous adapters keep their explicit + /// blocking boundary, not a synchronous signing callback invoked directly on Tokio. + pub(crate) async fn issue_async( + &self, + method: String, + path_and_query: String, + operation: String, + worker_id: Option, + body: Vec, + ) -> Result { + let executor = self.store.db_executor(); + let authorization = self.clone(); + executor + .run(move || { + Ok::<_, Error>(authorization.issue( + &method, + &path_and_query, + &operation, + worker_id.as_deref(), + &body, + )) + }) + .await + .map_err(|error| { + diagnostic( + "workspace_runtime_authorization_unavailable", + HostDiagnosticSeverity::Error, + error.to_string(), + ) + })? + } + pub(crate) fn issue( &self, method: &str, @@ -3657,25 +3710,24 @@ impl std::fmt::Debug for RemoteWorkdirAuthorization { impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization { fn bearer_token( &self, - method: &str, - path_and_query: &str, - body: &[u8], - ) -> Result { - if let Some(authorization) = &self.workspace_authorization { - return authorization - .issue( - method, - path_and_query, - workspace_runtime_operation(method, path_and_query), - None, - body, + method: String, + path_and_query: String, + body: Vec, + ) -> WorkdirHttpAuthorizationFuture { + let authorization = self.clone(); + Box::pin(async move { + if let Some(authorizer) = &authorization.workspace_authorization { + let operation = workspace_runtime_operation(&method, &path_and_query).to_string(); + return authorizer + .issue_async(method, path_and_query, operation, None, body) + .await + .map_err(|error| WorkdirError::Unavailable(error.message)); + } + authorization.fallback_bearer_token.ok_or_else(|| { + WorkdirError::Unavailable( + "remote Runtime does not have bearer authorization configured".to_string(), ) - .map_err(|error| WorkdirError::Unavailable(error.message)); - } - self.fallback_bearer_token.clone().ok_or_else(|| { - WorkdirError::Unavailable( - "remote Runtime does not have bearer authorization configured".to_string(), - ) + }) }) } } @@ -5298,20 +5350,22 @@ impl WorkspaceWorkerRuntime for RemoteWorkerRuntime { fn observation_source( &self, worker_id: &str, - ) -> Option { + ) -> Result, RuntimeDiagnostic> { let path = format!("/v1/workers/{worker_id}/protocol/ws"); let bearer_token = match &self.workspace_authorization { - Some(authorization) => authorization - .issue("GET", &path, "workers:protocol", Some(worker_id), &[]) - .ok(), + Some(authorization) => { + Some(authorization.issue("GET", &path, "workers:protocol", Some(worker_id), &[])?) + } None => self.bearer_token.clone(), }; - Some(crate::observation::RuntimeObservationSource::remote_ws( - crate::observation::RuntimeObservationSourceConfig { - worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id), - endpoint: self.ws_endpoint(worker_id), - bearer_token, - }, + Ok(Some( + crate::observation::RuntimeObservationSource::remote_ws( + crate::observation::RuntimeObservationSourceConfig { + worker: RuntimeWorkerRef::new(&self.runtime_id, worker_id), + endpoint: self.ws_endpoint(worker_id), + bearer_token, + }, + ), )) } @@ -6355,6 +6409,79 @@ mod tests { use std::sync::{Arc, Mutex}; use std::thread; + #[tokio::test] + async fn async_workdir_authorization_uses_store_capacity_without_bearer_fallback() { + use crate::db_executor::{DbExecutor, DbExecutorLimits}; + use crate::store::{SqliteWorkspaceStore, WorkspaceRuntimeBinding}; + use crate::workspace_signing_identity::InMemoryWorkspaceSigningMaterialStore; + + let executor = DbExecutor::new(DbExecutorLimits { + workers: std::num::NonZeroUsize::new(1).unwrap(), + queued: std::num::NonZeroUsize::new(1).unwrap(), + }) + .unwrap(); + let store = Arc::new( + SqliteWorkspaceStore::in_memory_for_test() + .unwrap() + .with_db_executor(executor.clone()), + ); + let service = WorkspaceSigningIdentityService::new( + store.clone(), + Arc::new(InMemoryWorkspaceSigningMaterialStore::default()), + ); + let authorization = RemoteWorkdirAuthorization { + workspace_authorization: Some(WorkspaceRuntimeAuthorization::new( + store, + service, + "https://backend.test", + WorkspaceRuntimeBinding { + workspace_id: "workspace".into(), + runtime_id: "runtime".into(), + display_name: "Runtime".into(), + base_url: "https://runtime.test".into(), + public_key_fingerprint: "unused".into(), + public_key: "unused".into(), + binding_id: "binding".into(), + authentication_mode: WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity, + created_at: "1".into(), + updated_at: "1".into(), + revoked_at: None, + }, + )), + fallback_bearer_token: Some("must-not-be-used".into()), + }; + + // Hold the running slot and fill the only queue slot. The callback must + // reject admission, not run inline or silently switch authentication. + let (release, wait) = std::sync::mpsc::channel::<()>(); + let (started, running) = tokio::sync::oneshot::channel(); + executor + .dispatch(move || { + let _ = started.send(()); + let _ = wait.recv_timeout(Duration::from_secs(5)); + }) + .unwrap(); + tokio::time::timeout(Duration::from_secs(5), running) + .await + .unwrap() + .unwrap(); + executor.dispatch(|| {}).unwrap(); + let error = authorization + .bearer_token( + "POST".into(), + "/v1/workdir-sessions/s/operations".into(), + b"{}".to_vec(), + ) + .await + .unwrap_err(); + assert!(matches!( + error, + WorkdirError::Unavailable(message) if message == Error::DbCapacityExceeded.to_string() + )); + drop(release); + executor.drain().await.unwrap(); + } + #[test] fn ticket_worker_spawn_intent_uses_worker_wire_role() { let intent = WorkerSpawnIntent::TicketRole { @@ -7556,6 +7683,7 @@ mod tests { fn worker_spawn_idempotency_fingerprint_covers_canonical_initial_submit() { let mut request = embedded_spawn_request(); request.ticket_assignment = Some(WorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: "00001KVZSGT0Q".to_string(), operation_id: "operation-1".to_string(), }); diff --git a/crates/workspace-server/src/hosts/tests/workspace_auth_tests.rs b/crates/workspace-server/src/hosts/tests/workspace_auth_tests.rs index f68964f35..29c0cb52c 100644 --- a/crates/workspace-server/src/hosts/tests/workspace_auth_tests.rs +++ b/crates/workspace-server/src/hosts/tests/workspace_auth_tests.rs @@ -32,7 +32,8 @@ struct Fixture { impl Fixture { async fn new() -> Self { let temp = tempfile::tempdir().unwrap(); - let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap()); + let store = + Arc::new(SqliteWorkspaceStore::open_for_test(temp.path().join("server.db")).unwrap()); store .upsert_account(&AccountRecord { account_id: "owner".into(), diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 1ce68d15e..76578bde6 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -328,31 +328,7 @@ CREATE TABLE server_secret_objects ( REFERENCES repository_ssh_credential_keys(workspace_id, credential_id, operation_id) ON DELETE CASCADE ); -CREATE TABLE ticket_assignment_operations ( - 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, - role TEXT NOT NULL DEFAULT 'worker' - CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), - principal_kind TEXT NOT NULL DEFAULT 'worker' - CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), - principal_id TEXT, - runtime_id TEXT, - worker_id TEXT, - assignment_id TEXT, - expected_assignment_id TEXT, - created_at TEXT NOT NULL, - request_fingerprint TEXT, - claim_state TEXT NOT NULL DEFAULT 'committed' - CHECK(claim_state IN ('pending', 'failed', 'committed')), - failure_reason TEXT, - binding_recovery_json TEXT, - PRIMARY KEY (workspace_id, operation_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - CHECK(claim_state = 'committed' OR assignment_id IS NULL), - CHECK((claim_state = 'failed') = (failure_reason IS NOT NULL)) - ); +-- Ticket-owned assignment schema is composed by store.rs. CREATE TABLE ticket_assignment_ticket_tombstones ( workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, @@ -368,72 +344,9 @@ CREATE TABLE ticket_assignment_worker_tombstones ( PRIMARY KEY (workspace_id, runtime_id, worker_id), FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE ); -CREATE TABLE ticket_current_worker_assignments ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'worker' - CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), - assignment_id TEXT NOT NULL, - principal_kind TEXT NOT NULL DEFAULT 'worker' - CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), - principal_id TEXT, - runtime_id TEXT, - worker_id TEXT, - updated_at TEXT NOT NULL, - PRIMARY KEY (workspace_id, ticket_id, role, assignment_id), - UNIQUE (workspace_id, assignment_id), - FOREIGN KEY (workspace_id, ticket_id, role, assignment_id) - REFERENCES ticket_worker_assignments(workspace_id, ticket_id, role, assignment_id) - ON DELETE CASCADE, - FOREIGN KEY (workspace_id, ticket_id) - REFERENCES typed_tickets(workspace_id, ticket_id) - ON DELETE CASCADE, - CHECK( - (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) - OR - (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) - ) - ); -CREATE TABLE ticket_worker_assignment_events ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'worker' - CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), - 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, - operation_id TEXT, - reason TEXT, - PRIMARY KEY (workspace_id, event_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE - ); -CREATE TABLE ticket_worker_assignments ( - workspace_id TEXT NOT NULL, - ticket_id TEXT NOT NULL, - assignment_id TEXT NOT NULL, - role TEXT NOT NULL DEFAULT 'worker' - CHECK(role IN ('orchestrator', 'worker', 'owner', 'contributor')), - principal_kind TEXT NOT NULL DEFAULT 'worker' - CHECK(principal_kind IN ('user', 'worker', 'workspace_agent')), - principal_id TEXT, - runtime_id TEXT, - worker_id TEXT, - 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, role, assignment_id), - UNIQUE (workspace_id, ticket_id, role, assignment_id, principal_kind, principal_id, runtime_id, worker_id), - FOREIGN KEY (workspace_id) REFERENCES workspaces(workspace_id) ON DELETE CASCADE, - CHECK( - (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) - OR - (principal_kind != 'worker' AND runtime_id IS NULL AND worker_id IS NULL AND principal_id IS NOT NULL AND length(trim(principal_id)) > 0) - ) - ); + + + CREATE TABLE workspace_runtime_bindings ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, @@ -1165,14 +1078,10 @@ CREATE INDEX idx_repository_ssh_credentials_workspace_status ON repository_ssh_credentials(workspace_id, status, credential_id); CREATE INDEX idx_repository_ssh_host_trusts_workspace_host ON repository_ssh_host_trusts(workspace_id, hostname, port); -CREATE INDEX idx_ticket_assignment_operations_ticket - ON ticket_assignment_operations(workspace_id, ticket_id, role, created_at DESC); -CREATE INDEX idx_ticket_worker_assignment_events_ticket - ON ticket_worker_assignment_events(workspace_id, ticket_id, role, created_at DESC); -CREATE INDEX idx_ticket_worker_assignments_principal - ON ticket_worker_assignments(workspace_id, role, principal_kind, principal_id, runtime_id, worker_id, assigned_at DESC); -CREATE INDEX idx_ticket_worker_assignments_ticket - ON ticket_worker_assignments(workspace_id, ticket_id, role, assigned_at DESC); + + + + CREATE INDEX idx_workspace_runtime_bindings_workspace ON workspace_runtime_bindings(workspace_id, revoked_at, runtime_id); CREATE INDEX idx_typed_ticket_relations_workspace_target @@ -1202,11 +1111,8 @@ CREATE INDEX idx_workspace_resource_keys_reverse ON workspace_resource_keys(workspace_id, resource_kind, resource_key); CREATE INDEX repositories_workspace_provider_idx ON repositories(workspace_id, provider); -CREATE INDEX ticket_current_principal_idx - ON ticket_current_worker_assignments(workspace_id, principal_kind, principal_id, runtime_id, worker_id); -CREATE UNIQUE INDEX ticket_current_singleton_role_idx - ON ticket_current_worker_assignments(workspace_id, ticket_id, role) - WHERE role IN ('orchestrator', 'worker'); + + CREATE INDEX typed_ticket_events_workspace_kind_ticket ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); CREATE UNIQUE INDEX ux_worker_workdir_attachment_reservation_id @@ -1683,3 +1589,114 @@ CREATE TABLE repository_secret_legacy_receipts ( FOREIGN KEY (workspace_id, operation_id) REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE ); + +-- T715: responsibility is retained; closing irreversibly releases an assignment identity. +CREATE TABLE IF NOT EXISTS ticket_assignment_work_releases ( + workspace_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + released_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, assignment_id), + FOREIGN KEY (workspace_id, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE +); +-- Ticket case assignment work constraints (schema 91). +DROP VIEW IF EXISTS ticket_active_worker_assignments; +CREATE VIEW ticket_active_worker_assignments AS + SELECT current.* FROM ticket_current_worker_assignments AS current + JOIN typed_tickets AS ticket + ON ticket.workspace_id = current.workspace_id AND ticket.ticket_id = current.ticket_id + WHERE ticket.workflow_state = 'open' + AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases AS released + WHERE released.workspace_id = current.workspace_id + AND released.assignment_id = current.assignment_id); +DROP TRIGGER IF EXISTS ticket_terminal_releases_work; +CREATE TRIGGER ticket_terminal_releases_work +AFTER UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state = 'closed' +BEGIN + INSERT OR IGNORE INTO ticket_assignment_work_releases + SELECT workspace_id, assignment_id, COALESCE(NEW.updated_at, updated_at) + FROM ticket_current_worker_assignments + WHERE workspace_id = NEW.workspace_id AND ticket_id = NEW.ticket_id; +END; +DROP TRIGGER IF EXISTS ticket_terminal_assignment_releases_work_insert; +CREATE TRIGGER ticket_terminal_assignment_releases_work_insert +AFTER INSERT ON ticket_current_worker_assignments +WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state = 'closed') +BEGIN + INSERT OR IGNORE INTO ticket_assignment_work_releases + VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at); +END; +DROP TRIGGER IF EXISTS ticket_terminal_assignment_releases_work_update; +CREATE TRIGGER ticket_terminal_assignment_releases_work_update +AFTER UPDATE ON ticket_current_worker_assignments +WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state = 'closed') +BEGIN + INSERT OR IGNORE INTO ticket_assignment_work_releases + VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at); +END; +DROP TRIGGER IF EXISTS ticket_active_worker_role_insert; +CREATE TRIGGER ticket_active_worker_role_insert +BEFORE INSERT ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' + AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state = 'open') + AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id) + AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = NEW.workspace_id AND role = NEW.role + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND assignment_id != NEW.assignment_id) +BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END; +DROP TRIGGER IF EXISTS ticket_active_worker_role_update; +CREATE TRIGGER ticket_active_worker_role_update +BEFORE UPDATE ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' + AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state = 'open') + AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id) + AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = NEW.workspace_id AND role = NEW.role + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND assignment_id != OLD.assignment_id) +BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END; +-- Keep archived responsibilities valid after Worker removal, but never allow +-- newly installed identities to refer to a removed / removal-reserved Worker. +DROP TRIGGER IF EXISTS ticket_active_worker_principal_insert; +CREATE TRIGGER ticket_active_worker_principal_insert +BEFORE INSERT ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' AND ( + NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id) + OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND state IN ('executing', 'failed', 'succeeded'))) +BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END; +DROP TRIGGER IF EXISTS ticket_active_worker_principal_update; +CREATE TRIGGER ticket_active_worker_principal_update +BEFORE UPDATE ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' AND ( + NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id) + OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND state IN ('executing', 'failed', 'succeeded'))) +BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END; +DROP TRIGGER IF EXISTS ticket_unfinished_work_blocks_worker_delete; +CREATE TRIGGER ticket_unfinished_work_blocks_worker_delete +BEFORE DELETE ON worker_registry +WHEN EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id + AND worker_id = OLD.worker_id) +BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END; +DROP TRIGGER IF EXISTS ticket_unfinished_work_blocks_worker_move; +CREATE TRIGGER ticket_unfinished_work_blocks_worker_move +BEFORE UPDATE OF runtime_id, worker_id, workspace_id ON worker_registry +WHEN (OLD.runtime_id != NEW.runtime_id OR OLD.worker_id != NEW.worker_id OR OLD.workspace_id != NEW.workspace_id) + AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id + AND worker_id = OLD.worker_id) +BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END; diff --git a/crates/workspace-server/src/legacy_evidence_column_tests.rs b/crates/workspace-server/src/legacy_evidence_column_tests.rs index a59f2a0ca..33994b292 100644 --- a/crates/workspace-server/src/legacy_evidence_column_tests.rs +++ b/crates/workspace-server/src/legacy_evidence_column_tests.rs @@ -45,7 +45,7 @@ fn evidence_column_cutover_preserves_all_values_and_current_schema_names() { let artifacts = evidence_rows(&conn, "artifacts"); let receipts = evidence_rows(&conn, "repository_secret_legacy_receipts"); drop(conn); - let upgraded = SqliteWorkspaceStore::open(&path).unwrap(); + let upgraded = SqliteWorkspaceStore::open_for_test(&path).unwrap(); upgraded .with_conn(|conn| { assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); @@ -73,7 +73,7 @@ fn evidence_column_cutover_preserves_all_values_and_current_schema_names() { Ok(()) }) .unwrap(); - for store in [upgraded, SqliteWorkspaceStore::in_memory().unwrap()] { + for store in [upgraded, SqliteWorkspaceStore::in_memory_for_test().unwrap()] { store.with_conn(|conn| { let mut stmt = conn.prepare("SELECT name,sql FROM sqlite_schema WHERE sql IS NOT NULL AND name NOT LIKE 'sqlite_%'")?; let objects = stmt.query_map([],|row|Ok((row.get::<_,String>(0)?,row.get::<_,String>(1)?)))?.collect::>>()?; diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index 5d98d2fbd..9734ff34d 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -6,6 +6,7 @@ pub mod backend_job; pub mod companion; pub mod config; pub mod config_source; +pub mod db_executor; pub mod feature_storage; pub mod hosts; pub mod memory_backend; @@ -155,6 +156,12 @@ pub enum Error { WorkspaceSigningIdentity { code: String, message: String }, #[error("Workspace Drive error: {0}")] Drive(#[from] workspace_drive::Error), + #[error("DB execution capacity exceeded")] + DbCapacityExceeded, + #[error("DB executor unavailable")] + DbExecutorUnavailable, + #[error("DB operation outcome unknown; do not automatically retry")] + DbOperationOutcomeUnknown, #[error("store error: {0}")] Store(String), } diff --git a/crates/workspace-server/src/memory_staging.rs b/crates/workspace-server/src/memory_staging.rs index ff8caadf7..631d1b33e 100644 --- a/crates/workspace-server/src/memory_staging.rs +++ b/crates/workspace-server/src/memory_staging.rs @@ -217,7 +217,7 @@ mod tests { async fn authority() -> (TempDir, SqliteWorkspaceAuthority) { let temp = TempDir::new().unwrap(); let db_path = temp.path().join("workspace.db"); - let store = SqliteWorkspaceStore::open(&db_path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&db_path).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-test".to_string(), diff --git a/crates/workspace-server/src/migration_entrypoint_tests.rs b/crates/workspace-server/src/migration_entrypoint_tests.rs index d0eb2375d..41329a730 100644 --- a/crates/workspace-server/src/migration_entrypoint_tests.rs +++ b/crates/workspace-server/src/migration_entrypoint_tests.rs @@ -32,7 +32,7 @@ fn opening_schema_84_with_uncertain_removal_preserves_old_server_recovery() { drop(conn); for attempt in 0..3 { let error = match attempt { - 0 => SqliteWorkspaceStore::open(&path).err().unwrap(), + 0 => SqliteWorkspaceStore::open_for_test(&path).err().unwrap(), 1 => SqliteWorkspaceStore::migration_plan(&path).unwrap_err(), _ => SqliteWorkspaceStore::migrate_database(&path).unwrap_err(), }; @@ -62,7 +62,7 @@ fn opening_schema_84_with_uncertain_removal_preserves_old_server_recovery() { ) .unwrap(); drop(conn); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store.with_conn(|conn| { assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!(conn.query_row("SELECT input_fingerprint FROM worker_removal_operations WHERE operation_id='removal'",[],|r|r.get::<_,String>(0))?,"legacy-receipt"); @@ -109,7 +109,7 @@ fn migration_dry_run_and_apply_share_the_original_repository_secret_authority() assert!(!table_exists(&conn, "server_secret_objects").unwrap()); drop(conn); assert_eq!(SqliteWorkspaceStore::migrate_database(&path).unwrap(), plan); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store.with_conn(|conn| { assert_eq!(current_schema_version(conn)?,LATEST_SCHEMA_VERSION); let (nonce, mut sealed): (Vec,Vec) = conn.query_row( diff --git a/crates/workspace-server/src/repository_access.rs b/crates/workspace-server/src/repository_access.rs index 304909848..c8a13c3f4 100644 --- a/crates/workspace-server/src/repository_access.rs +++ b/crates/workspace-server/src/repository_access.rs @@ -2290,7 +2290,7 @@ mod tests { ) { let dir = tempfile::tempdir().unwrap(); let database = dir.path().join("server.db"); - let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database).unwrap()); let timestamp = now(); for workspace_id in ["workspace-a", "workspace-b"] { futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord { diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index 340925a2e..4aa310885 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -1116,7 +1116,7 @@ mod tests { WorkerId::from_legacy_u64(1) } fn setup() -> SqliteWorkspaceStore { - let s = SqliteWorkspaceStore::in_memory().unwrap(); + let s = SqliteWorkspaceStore::in_memory_for_test().unwrap(); s.with_conn(|c| { c.execute( "INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) \ @@ -1136,7 +1136,7 @@ mod tests { )?; 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)", + VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'open', 1)", [], )?; Ok(()) @@ -1398,7 +1398,7 @@ mod tests { }) .unwrap(); 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 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','','open',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','2026-08-24T00:00:00Z')", [])?; conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?; 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')", [])?; @@ -1522,24 +1522,38 @@ mod tests { install_current_assignment(s, "assignment").unwrap(); } - fn reopened_terminal_assignment_store(terminal_state: &str) -> SqliteWorkspaceStore { + fn reopened_terminal_assignment_store() -> SqliteWorkspaceStore { let store = setup(); seed_current_assignment(&store); store .with_conn(|conn| { conn.execute( - "UPDATE typed_tickets SET workflow_state=?1 + "UPDATE typed_tickets SET status='closed', workflow_state='closed' WHERE workspace_id='w' AND ticket_id='ticket'", - [terminal_state], + [], )?; conn.execute( - "UPDATE typed_tickets SET workflow_state='planning' + "UPDATE typed_tickets SET status='open', workflow_state='open' WHERE workspace_id='w' AND ticket_id='ticket'", [], )?; Ok(()) }) .unwrap(); + // Narrow lifecycle-schema evidence: closing durably releases work, + // while reopening retains routing without reviving that assignment. + let released: i64 = store + .with_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_work_releases + WHERE workspace_id='w' AND assignment_id='assignment'", + [], + |row| row.get(0), + ) + .map_err(StoreError::from) + }) + .unwrap(); + assert_eq!(released, 1); store } @@ -1646,103 +1660,93 @@ mod tests { #[test] fn terminal_retained_assignment_allows_worker_removal_even_after_reopen() { - for (status, workflow_state) in [("closed", "closed"), ("open", "done")] { - for reopen in [false, true] { - let store = setup(); - seed_current_assignment(&store); - store - .with_conn(|conn| { - conn.execute( - "UPDATE typed_tickets SET status=?1, workflow_state=?2 + for reopen in [false, true] { + let store = setup(); + seed_current_assignment(&store); + store + .with_conn(|conn| { + conn.execute( + "UPDATE typed_tickets SET status='closed', workflow_state='closed' WHERE workspace_id='w' AND ticket_id='ticket'", - params![status, workflow_state], - )?; - if reopen { - conn.execute( - "UPDATE typed_tickets SET status='open', workflow_state='planning' + [], + )?; + if reopen { + conn.execute( + "UPDATE typed_tickets SET status='open', workflow_state='open' WHERE workspace_id='w' AND ticket_id='ticket'", - [], - )?; - } - Ok(()) - }) - .unwrap(); - store - .update_worker_retention_policy( - "w", - &store - .worker_retention_policy("w") - .unwrap() - .unwrap() - .policy_digest, - &WorkerRetentionPolicyUpdate { - policy_id: "purge".into(), - session_disposition: SessionDisposition::Purge, - metadata_disposition: MetadataDisposition::Tombstone, - archive_retention: ArchiveRetention::Forever, - diagnostics_disposition: DiagnosticsDisposition::Purge, - diagnostics_retention_seconds: None, - }, - ) - .unwrap(); - let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); - assert!( - plan.blockers.is_empty(), - "{status}/{workflow_state}, reopened={reopen}" - ); - store - .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) - .unwrap(); - let result = WorkerRetentionExecutionResult { - operation_id: plan.operation_id.clone(), - input_fingerprint: plan.input_fingerprint.clone(), - worker_id: worker_id(), - session_disposition: plan.session_disposition, - diagnostics_disposition: plan.diagnostics_disposition, - archive: None, - source_removed: true, - diagnostics_retained: false, - }; - let committed = store - .commit_worker_removal( - "w", - &plan.operation_id, - &plan.input_fingerprint, - &result, - ) - .unwrap(); - assert_eq!(committed.plan.state, WorkerRemovalPlanState::Succeeded); - assert!( - store - .get_worker_registry("w", &plan.worker) + [], + )?; + } + Ok(()) + }) + .unwrap(); + store + .update_worker_retention_policy( + "w", + &store + .worker_retention_policy("w") .unwrap() - .is_none() - ); - assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_some()); - let (retained, historical): (i64, i64) = store - .with_conn(|conn| { - Ok(( - conn.query_row( - "SELECT COUNT(*) FROM ticket_current_worker_assignments + .unwrap() + .policy_digest, + &WorkerRetentionPolicyUpdate { + policy_id: "purge".into(), + session_disposition: SessionDisposition::Purge, + metadata_disposition: MetadataDisposition::Tombstone, + archive_retention: ArchiveRetention::Forever, + diagnostics_disposition: DiagnosticsDisposition::Purge, + diagnostics_retention_seconds: None, + }, + ) + .unwrap(); + let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); + assert!(plan.blockers.is_empty(), "closed case, reopened={reopen}"); + store + .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) + .unwrap(); + let result = WorkerRetentionExecutionResult { + operation_id: plan.operation_id.clone(), + input_fingerprint: plan.input_fingerprint.clone(), + worker_id: worker_id(), + session_disposition: plan.session_disposition, + diagnostics_disposition: plan.diagnostics_disposition, + archive: None, + source_removed: true, + diagnostics_retained: false, + }; + let committed = store + .commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result) + .unwrap(); + assert_eq!(committed.plan.state, WorkerRemovalPlanState::Succeeded); + assert!( + store + .get_worker_registry("w", &plan.worker) + .unwrap() + .is_none() + ); + assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_some()); + let (retained, historical): (i64, i64) = store + .with_conn(|conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM ticket_current_worker_assignments WHERE workspace_id='w' AND assignment_id='assignment'", - [], - |row| row.get(0), - )?, - conn.query_row( - "SELECT COUNT(*) FROM ticket_worker_assignments + [], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id='w' AND assignment_id='assignment'", - [], - |row| row.get(0), - )?, - )) - }) - .unwrap(); - assert_eq!( - (retained, historical), - (1, 1), - "assignment history must survive removal" - ); - } + [], + |row| row.get(0), + )?, + )) + }) + .unwrap(); + assert_eq!( + (retained, historical), + (1, 1), + "assignment history must survive removal" + ); } } @@ -1768,42 +1772,40 @@ mod tests { #[test] fn distinct_assignment_after_terminal_end_blocks_preparation() { - for terminal_state in ["done", "closed"] { - let store = reopened_terminal_assignment_store(terminal_state); - let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); - assert!(plan.blockers.is_empty()); - install_current_assignment(&store, "new-assignment").unwrap(); - let blocked = store.plan_worker_removal(&req(), &inv()).unwrap(); - assert_eq!( - blocked.blockers, - vec![WorkerRemovalBlocker::UnfinishedWork { - assignment_id: "new-assignment".into(), - ticket_id: "ticket".into(), - }] - ); - let replay = store.plan_worker_removal(&req(), &inv()).unwrap(); - assert_eq!(replay.plan_id, blocked.plan_id); - assert_eq!(replay.input_fingerprint, blocked.input_fingerprint); - assert_ne!(blocked.plan_id, plan.plan_id); - let error = store - .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) - .unwrap_err(); - assert!( - matches!(error, WorkerRetentionError::StalePlan { reason, .. } + let store = reopened_terminal_assignment_store(); + let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); + assert!(plan.blockers.is_empty()); + install_current_assignment(&store, "new-assignment").unwrap(); + let blocked = store.plan_worker_removal(&req(), &inv()).unwrap(); + assert_eq!( + blocked.blockers, + vec![WorkerRemovalBlocker::UnfinishedWork { + assignment_id: "new-assignment".into(), + ticket_id: "ticket".into(), + }] + ); + let replay = store.plan_worker_removal(&req(), &inv()).unwrap(); + assert_eq!(replay.plan_id, blocked.plan_id); + assert_eq!(replay.input_fingerprint, blocked.input_fingerprint); + assert_ne!(blocked.plan_id, plan.plan_id); + let error = store + .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) + .unwrap_err(); + assert!( + matches!(error, WorkerRetentionError::StalePlan { reason, .. } if reason == "unfinished work added") - ); - assert!( - store - .get_worker_registry("w", &plan.worker) - .unwrap() - .is_some() - ); - } + ); + assert!( + store + .get_worker_registry("w", &plan.worker) + .unwrap() + .is_some() + ); } #[test] fn prepared_removal_fences_distinct_assignment_after_terminal_end() { - let store = reopened_terminal_assignment_store("done"); + let store = reopened_terminal_assignment_store(); let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); store .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) @@ -1837,7 +1839,7 @@ mod tests { #[test] fn commit_rechecks_distinct_assignment_independently_of_admission_fence() { - let store = reopened_terminal_assignment_store("done"); + let store = reopened_terminal_assignment_store(); let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); store .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) @@ -1938,7 +1940,7 @@ mod tests { store .with_conn(|conn| { conn.execute( - "UPDATE typed_tickets SET workflow_state='done' + "UPDATE typed_tickets SET status='closed', workflow_state='closed' WHERE workspace_id='w' AND ticket_id='ticket'", [], )?; diff --git a/crates/workspace-server/src/runtime_subscription.rs b/crates/workspace-server/src/runtime_subscription.rs index 3a7fdd0bd..998786c38 100644 --- a/crates/workspace-server/src/runtime_subscription.rs +++ b/crates/workspace-server/src/runtime_subscription.rs @@ -965,7 +965,7 @@ async fn connect_runtime( let mut request = endpoint .into_client_request() .map_err(|error| format!("invalid Runtime subscription endpoint: {error}"))?; - if let Some(token) = runtime_token(config, workspace_id)? { + if let Some(token) = runtime_token(config, workspace_id).await? { request.headers_mut().insert( "authorization", format!("Bearer {token}") @@ -1021,13 +1021,20 @@ fn runtime_endpoint(base_url: &str) -> String { format!("{base}/v1/protocol/ws") } } -fn runtime_token( +async fn runtime_token( config: &RemoteRuntimeConfig, _workspace_id: &str, ) -> Result, String> { if let Some(authorization) = config.workspace_authorization.as_ref() { return authorization - .issue("GET", "/v1/protocol/ws", "workers:list", None, &[]) + .issue_async( + "GET".to_string(), + "/v1/protocol/ws".to_string(), + "workers:list".to_string(), + None, + Vec::new(), + ) + .await .map(Some) .map_err(|error| error.message); } diff --git a/crates/workspace-server/src/schema_equivalence_tests.rs b/crates/workspace-server/src/schema_equivalence_tests.rs index 3d98af689..4063a675b 100644 --- a/crates/workspace-server/src/schema_equivalence_tests.rs +++ b/crates/workspace-server/src/schema_equivalence_tests.rs @@ -38,10 +38,20 @@ fn fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("upgraded.db"); prepare_retained_schema(&path, 85); - let upgraded = SqliteWorkspaceStore::open(&path).unwrap(); - let fresh = SqliteWorkspaceStore::in_memory().unwrap(); + let upgraded = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap(); let mut mismatches = Vec::new(); for table in [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + "typed_tickets", + "typed_ticket_targets", + "typed_ticket_case_metadata", + "typed_ticket_case_history", + "typed_ticket_legacy_workflow_snapshot", + "ticket_current_worker_assignments", + "ticket_assignment_work_releases", "artifacts", "workspace_config_trees", "workspace_config_tree_history", diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 90b6f6a7a..f3dcac742 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -12,21 +12,28 @@ //! //! # Tickets and Orchestrators //! -//! Ticket operations persist through the domain backend. Current Workspace role assignments -//! constrain Worker-origin state decisions. Queue and event notifications do not imply -//! implementation completion. Runtime Worker spawning, Workdir linking, and Ticket assignment -//! use their respective durable operations. Orchestrator backlog attention rereads current -//! Tickets and interested assignments at startup, Restore, and turn end. Subscription snapshots -//! and reconnection are not turn-end events; Running-to-Idle events are handled separately. +//! Ticket case judgments, assignment handling, operational delivery/start evidence and Worker +//! lifecycle are separate authorities. Open is not queued; closed need not mean fulfilled. Case +//! judgment requires no universal Repository/MR approval gate and handling done is not closure. +//! Current authenticated role/source constraints govern mutations, never model role text. +//! +//! Assign commits delegation and input origin before notification. Startup, create/replacement, +//! accepted Restore and Running-to-Idle attention use the same durable pending-input scan; waiting +//! alone and subscription snapshots are not new inputs. Clear/reissue/close fence old inputs; +//! reopen alone revives no work rights. Runtime acceptance is not model processing or progress. +//! Runtime spawning, Workdir links and actual work claims keep their own durable guards. //! This coordinates notifications, not a general event queue or worktree layout policy. use subjektiv::api::*; mod backend_job_profile; mod subjektiv_job; mod worker_operations; +mod worker_protocol; pub(crate) use worker_operations::{ WorkerOperationContext, WorkspaceWorker, WorkspaceWorkerMethodSender, }; +pub(crate) use worker_protocol::WorkerProtocolEnd; +use worker_protocol::*; #[path = "server_drive.rs"] mod drive; @@ -120,8 +127,8 @@ use server_api::{TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_P use sha2::{Digest, Sha256}; use ticket::{ MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind, - TicketIdOrSlug, TicketItemEdit, TicketStateChange, TicketTargetAccess, TicketTargetsEdit, - TicketWorkflowState, + TicketIdOrSlug, TicketItemEdit, TicketState, TicketStateChange, TicketTargetAccess, + TicketTargetsEdit, }; use ticket::{ SqliteTicketBackend, TicketBackendOperation, TicketBackendOperationResult, @@ -911,7 +918,7 @@ impl ExternalProviderWorkdirSession { } } - fn audit_operation( + async fn audit_operation( &self, operation_id: &ExternalWorkdirOperationId, operation_kind: &str, @@ -920,25 +927,34 @@ impl ExternalProviderWorkdirSession { let Some(audit) = &self.audit else { return Ok(()); }; + let audit = audit.clone(); + let operation_id = operation_id.clone(); + let operation_kind = operation_kind.to_owned(); + let outcome = outcome.to_owned(); + let connection = self.connection.clone(); audit .store - .record_external_workdir_audit( - &audit.workspace_id, - "worker", - &audit.actor_key, - "external_workdir_operation", - &self.connection.grant_id, - Some(operation_id.as_str()), - outcome, - &format!( - "workdir_id={} attachment_alias={} operation_kind={} generation={}", - self.connection.workdir_id, - audit.attachment_alias, - operation_kind, - self.connection.generation - ), - &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), - ) + .db_executor() + .run(move || { + audit.store.record_external_workdir_audit( + &audit.workspace_id, + "worker", + &audit.actor_key, + "external_workdir_operation", + &connection.grant_id, + Some(operation_id.as_str()), + &outcome, + &format!( + "workdir_id={} attachment_alias={} operation_kind={} generation={}", + connection.workdir_id, + audit.attachment_alias, + operation_kind, + connection.generation + ), + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) + }) + .await .map_err(|error| { workdir::WorkdirError::Unavailable(format!( "External Workdir operation audit failed: {error}" @@ -986,7 +1002,8 @@ impl ExternalProviderWorkdirSession { } let operation_id = ExternalWorkdirOperationId::new(format!("op-{}", Uuid::now_v7())) .map_err(workdir::WorkdirError::InvalidArgument)?; - self.audit_operation(&operation_id, operation_kind, "requested")?; + self.audit_operation(&operation_id, operation_kind, "requested") + .await?; let (response, receive) = tokio::sync::oneshot::channel(); let mut cancel_guard = ExternalOperationCancelGuard { sender: self.connection.sender.clone(), @@ -1003,7 +1020,8 @@ impl ExternalProviderWorkdirSession { .await .is_err() { - self.audit_operation(&operation_id, operation_kind, "provider_unavailable")?; + self.audit_operation(&operation_id, operation_kind, "provider_unavailable") + .await?; return Err(workdir::WorkdirError::Unavailable( "External Workdir provider disconnected".to_string(), )); @@ -1033,7 +1051,8 @@ impl ExternalProviderWorkdirSession { } else { "failed" }, - )?; + ) + .await?; result } @@ -1554,7 +1573,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { #[derive(Default)] struct OrchestratorAttentionState { sending: bool, - restore_recheck: bool, + input_recheck: bool, accepted: Option, pending: Option, } @@ -1564,6 +1583,7 @@ struct OrchestratorAttentionDelivery { fingerprint: String, request_id: String, content: String, + input: crate::store::TicketAssignmentDelivery, } struct OrchestratorAttentionReservation(Option>>); @@ -1572,10 +1592,11 @@ impl OrchestratorAttentionReservation { fn complete(mut self) -> bool { let state = self.0.take().expect("active attention reservation"); let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); - // Release and take deferred work atomically. A Restore arriving after - // release can reserve its own check; one arriving before is ours to drain. + // Release and take deferred input checks atomically. A committed input or + // Restore arriving before release is ours to drain; one after release + // can reserve its own check. state.sending = false; - std::mem::take(&mut state.restore_recheck) + std::mem::take(&mut state.input_recheck) } } @@ -1601,6 +1622,7 @@ fn orchestrator_attention_state( #[derive(Clone)] pub struct WorkspaceApi { + host_executors: HostExecutors, pub(crate) config: ServerConfig, pub(crate) store: Arc, config_store: Arc, @@ -1699,6 +1721,7 @@ impl ServerApiContractService { struct WorkerRemovalService { workspace_id: String, store: Arc, + host_executors: HostExecutors, runtime: Weak, workdir_sessions: Arc>, workdir_session_locks: Arc>>>>, @@ -1712,6 +1735,7 @@ impl WorkerRemovalService { Self { workspace_id: api.config.workspace_id.clone(), store: api.store.clone(), + host_executors: api.host_executors.clone(), runtime: Arc::downgrade(&api.runtime), workdir_sessions: api.workdir_sessions.clone(), workdir_session_locks: api.workdir_session_locks.clone(), @@ -1877,6 +1901,30 @@ impl WorkerRemovalService { target_runtime_id: &str, target_worker_id: &str, reason: &str, + ) -> std::result::Result { + let service = self.clone(); + let runtime_id = target_runtime_id.to_owned(); + let worker_id = target_worker_id.to_owned(); + let reason = reason.to_owned(); + run_host_workflow( + self.host_executors.host().map_err(|e| e.to_string())?, + async move { + service + .execute_async_on_host(source, &runtime_id, &worker_id, &reason) + .await + .map_err(Error::Store) + }, + ) + .await + .map_err(|error| error.to_string()) + } + + async fn execute_async_on_host( + &self, + source: crate::worker_source::VerifiedWorkerMutationSource, + target_runtime_id: &str, + target_worker_id: &str, + reason: &str, ) -> std::result::Result { let reason = reason.trim(); if reason.is_empty() || reason.len() > 512 { @@ -1948,11 +1996,39 @@ impl WorkerRemovalService { )); } - self.execute_target_removal(&runtime, &target, reason, None) + self.execute_target_removal_on_host(&runtime, &target, reason, None) .await } async fn execute_target_removal( + &self, + _runtime: &RuntimeRegistry, + target: &RuntimeWorkerRef, + reason: &str, + parent_workspace_deletion_operation_id: Option<&str>, + ) -> std::result::Result { + let runtime = self + .runtime + .upgrade() + .ok_or_else(|| "Workspace Runtime registry is unavailable".to_string())?; + let service = self.clone(); + let target = target.clone(); + let reason = reason.to_owned(); + let parent = parent_workspace_deletion_operation_id.map(str::to_owned); + run_host_workflow( + self.host_executors.host().map_err(|e| e.to_string())?, + async move { + service + .execute_target_removal_on_host(&runtime, &target, &reason, parent.as_deref()) + .await + .map_err(Error::Store) + }, + ) + .await + .map_err(|error| error.to_string()) + } + + async fn execute_target_removal_on_host( &self, runtime: &RuntimeRegistry, target: &RuntimeWorkerRef, @@ -2201,6 +2277,7 @@ use workspace_admission::WorkspaceAdmission; #[derive(Clone)] pub struct WorkspaceServerApi { + host_executors: HostExecutors, template: Arc, store: Arc, catalog: WorkspaceCatalogService, @@ -2231,6 +2308,7 @@ impl WorkspaceServerApi { workspace_signing_material_root(&template.database_path), )); Self { + host_executors: HostExecutors::shared(), template: Arc::new(template), catalog: WorkspaceCatalogService::new(store.clone(), signing_materials.clone()), signing_materials, @@ -2246,6 +2324,23 @@ impl WorkspaceServerApi { } } + #[cfg(test)] + fn new_for_test(template: ServerConfig, store: Arc) -> Self { + let mut api = Self::new(template, store); + api.host_executors = HostExecutors::default(); + api + } + + // Own the complete synchronous store stage; callers retain their async + // lifecycle guards across it and never split a transaction around await. + async fn execute_store_stage( + &self, + operation: impl FnOnce(Arc) -> Result + Send + 'static, + ) -> Result { + let store = self.store.clone(); + self.store.db_executor().run(move || operation(store)).await + } + async fn admission_for_workspace(&self, workspace_id: &str) -> Arc { workspace_admission_gate(&self.admission_gates, workspace_id).await } @@ -2258,11 +2353,22 @@ impl WorkspaceServerApi { let Some(workspace) = self.store.get_workspace(workspace_id).await? else { return Ok(None); }; - let repositories = self.store.list_repositories(workspace_id)?; + let store = self.store.clone(); + let id = workspace_id.to_owned(); + let repositories = self + .store + .db_executor() + .run(move || store.list_repositories(&id)) + .await?; let config = self .template .for_catalog_workspace(&workspace, repositories)?; - let api = WorkspaceApi::new(config, self.store.clone()).await?; + let api = WorkspaceApi::new_with_host_executors( + config, + self.store.clone(), + self.host_executors.clone(), + ) + .await?; apis.insert(workspace_id.to_string(), api.clone()); Ok(Some(api)) } @@ -2275,34 +2381,46 @@ impl WorkspaceServerApi { drop(running); let api = self.clone(); tokio::spawn(async move { - if api.execute_workspace_deletion(&operation_id).await.is_err() { - let operation = api - .store - .workspace_deletion_operation_for_recovery(&operation_id) - .ok() - .flatten(); - let child_operation_ids = operation - .as_ref() - .map(|operation| operation.child_operation_ids.as_slice()) - .unwrap_or_default(); - let blockers = operation - .as_ref() - .map(|operation| operation.blockers.as_slice()) - .unwrap_or_default(); - let _ = api.store.update_workspace_deletion_operation( - &operation_id, - WorkspaceDeletionState::Failed, - child_operation_ids, - blockers, - Some("workspace_deletion_execution_failed"), - ); + if let Err(error) = api.execute_workspace_deletion(&operation_id).await { + tracing::warn!(%error, %operation_id, "Workspace deletion execution did not complete"); + if !matches!(error, Error::DbOperationOutcomeUnknown) { + let id = operation_id.clone(); + let _ = api + .execute_store_stage(move |store| { + let operation = store + .workspace_deletion_operation_for_recovery(&id) + .ok() + .flatten(); + let child_operation_ids = operation + .as_ref() + .map(|operation| operation.child_operation_ids.as_slice()) + .unwrap_or_default(); + let blockers = operation + .as_ref() + .map(|operation| operation.blockers.as_slice()) + .unwrap_or_default(); + store + .update_workspace_deletion_operation( + &id, + WorkspaceDeletionState::Failed, + child_operation_ids, + blockers, + Some("workspace_deletion_execution_failed"), + ) + .map(drop) + }) + .await; + } } api.running_deletions.lock().await.remove(&operation_id); }); } async fn recover_workspace_deletions(&self) -> Result<()> { - for operation_id in self.store.resumable_workspace_deletion_operation_ids()? { + let operations = self + .execute_store_stage(|store| store.resumable_workspace_deletion_operation_ids()) + .await?; + for operation_id in operations { self.schedule_workspace_deletion(operation_id).await; } Ok(()) @@ -2313,16 +2431,20 @@ impl WorkspaceServerApi { actor_account_id: &str, workspace_id: &str, ) -> Result { + let actor = actor_account_id.to_owned(); + let id = workspace_id.to_owned(); let mut preflight = self - .store - .workspace_deletion_preflight(actor_account_id, workspace_id)?; + .execute_store_stage(move |store| store.workspace_deletion_preflight(&actor, &id)) + .await?; let Some(api) = self.api_for_workspace(workspace_id).await? else { return Err(Error::InvalidInput("Workspace does not exist".to_string())); }; - for registry_worker in self - .store - .list_worker_registry(workspace_id, i64::MAX as usize)? - { + let id = workspace_id.to_owned(); + let workers = self + .execute_store_stage(move |store| store.list_worker_registry(&id, i64::MAX as usize)) + .await?; + api.host_executors.host()?.run(move || -> Result<_> { + for registry_worker in workers { let worker_key = registry_worker.display_name; match api.runtime.worker(®istry_worker.worker) { Ok(worker) if worker.state == "stopped" => {} @@ -2345,23 +2467,30 @@ impl WorkspaceServerApi { crate::workspace_deletion::bound_workspace_deletion_blockers(&mut preflight.blockers); preflight.can_delete = preflight.blockers.is_empty(); Ok(preflight) + }).await } async fn execute_workspace_deletion( &self, operation_id: &str, ) -> Result { - let prior = self - .store - .workspace_deletion_operation_for_recovery(operation_id)? - .ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))?; - let operation = self.store.update_workspace_deletion_operation( - operation_id, - WorkspaceDeletionState::Running, - &prior.child_operation_ids, - &[], - None, - )?; + let id = operation_id.to_owned(); + let operation = self + .execute_store_stage(move |store| { + let prior = store + .workspace_deletion_operation_for_recovery(&id)? + .ok_or_else(|| { + Error::InvalidInput("Workspace deletion operation".to_string()) + })?; + store.update_workspace_deletion_operation( + &id, + WorkspaceDeletionState::Running, + &prior.child_operation_ids, + &[], + None, + ) + }) + .await?; let api = self .api_for_workspace(&operation.workspace_id) .await? @@ -2370,10 +2499,13 @@ impl WorkspaceServerApi { api.fence_drive().await?; let mut child_operation_ids = operation.child_operation_ids.clone(); let mut blockers = Vec::new(); - for worker in self - .store - .list_worker_registry(&operation.workspace_id, i64::MAX as usize)? - { + let workspace_id = operation.workspace_id.clone(); + let workers = self + .execute_store_stage(move |store| { + store.list_worker_registry(&workspace_id, i64::MAX as usize) + }) + .await?; + for worker in workers { let worker_key = worker.display_name.clone(); let target = worker.worker; let response = WorkerRemovalService::new(&api) @@ -2385,13 +2517,17 @@ impl WorkspaceServerApi { ) .await .map_err(Error::Store)?; + let id = operation_id.to_owned(); child_operation_ids = self - .store - .workspace_deletion_operation_for_recovery(operation_id)? - .ok_or_else(|| { - Error::Store("Workspace deletion operation disappeared".to_string()) - })? - .child_operation_ids; + .execute_store_stage(move |store| { + store + .workspace_deletion_operation_for_recovery(&id)? + .ok_or_else(|| { + Error::Store("Workspace deletion operation disappeared".to_string()) + }) + .map(|operation| operation.child_operation_ids) + }) + .await?; if response.status != 200 { blockers.push(WorkspaceDeletionBlocker { kind: WorkspaceDeletionBlockerKind::WorkerRemovalBlocked, @@ -2403,22 +2539,36 @@ impl WorkspaceServerApi { } if blockers.is_empty() { - for workdir in self - .store - .list_workdir_registry(&operation.workspace_id, i64::MAX as usize)? - { - match execute_workdir_removal_for_workspace_deletion( - &api, - &workdir.workdir_id, - operation_id, - ) { + let workspace_id = operation.workspace_id.clone(); + let workdirs = self + .execute_store_stage(move |store| { + store.list_workdir_registry(&workspace_id, i64::MAX as usize) + }) + .await?; + for workdir in workdirs { + let removal_api = api.clone(); + let workdir_id = workdir.workdir_id.clone(); + let parent_id = operation_id.to_owned(); + match api + .host_executors + .host()? + .run(move || { + execute_workdir_removal_for_workspace_deletion( + &removal_api, + &workdir_id, + &parent_id, + ) + }) + .await + { Ok(child) => { + let id = operation_id.to_owned(); + let child_id = child.operation_id.clone(); child_operation_ids = self - .store - .append_workspace_deletion_child_operation( - operation_id, - &child.operation_id, - )? + .execute_store_stage(move |store| { + store.append_workspace_deletion_child_operation(&id, &child_id) + }) + .await? .child_operation_ids; if child.state != WorkdirRemovalOperationState::Completed || child.disposition != Some(WorkdirRemovalDisposition::Removed) @@ -2454,29 +2604,40 @@ impl WorkspaceServerApi { } if !blockers.is_empty() { - return self.store.update_workspace_deletion_operation( - operation_id, - WorkspaceDeletionState::Blocked, - &child_operation_ids, - &blockers, - None, - ); + let id = operation_id.to_owned(); + return self + .execute_store_stage(move |store| { + store.update_workspace_deletion_operation( + &id, + WorkspaceDeletionState::Blocked, + &child_operation_ids, + &blockers, + None, + ) + }) + .await; } api.purge_drive().await?; let feature_storage = api.feature_storage.clone(); - tokio::task::spawn_blocking(move || feature_storage.delete()) - .await - .map_err(|error| { - Error::Store(format!("Feature storage deletion task failed: {error}")) - })? - .map_err(|error| { - Error::Store(format!( - "failed to delete Workspace Feature storage: {error}" - )) - })?; - WorkspaceSigningIdentityService::new(self.store.clone(), self.signing_materials.clone()) - .delete_material(&operation.workspace_id)?; - let completed = self.store.finalize_workspace_deletion(operation_id)?; + api.store + .db_executor() + .run(move || { + feature_storage.delete().map_err(|error| { + Error::Store(format!( + "failed to delete Workspace Feature storage: {error}" + )) + }) + }) + .await?; + let materials = self.signing_materials.clone(); + let id = operation_id.to_owned(); + let completed = self + .execute_store_stage(move |store| { + WorkspaceSigningIdentityService::new(store.clone(), materials) + .delete_material(&operation.workspace_id)?; + store.finalize_workspace_deletion(&id) + }) + .await?; self.routers.lock().await.remove(&completed.workspace_id); if let Some(handle) = self .hook_handles @@ -2523,15 +2684,19 @@ impl WorkspaceServerApi { async fn shutdown_feature_storage(&self) -> Result<()> { let apis = self.apis.lock().await.values().cloned().collect::>(); for api in apis { - api.feature_storage.shutdown().map_err(|error| { - Error::Store(format!("failed to shut down Feature storage: {error}")) - })?; + shutdown_workspace_feature_storage(&api).await?; } Ok(()) } async fn preload(&self) -> Result<()> { - for workspace in self.store.list_workspaces()? { + let store = self.store.clone(); + let workspaces = self + .store + .db_executor() + .run(move || store.list_workspaces()) + .await?; + for workspace in workspaces { let _ = self .router_for_workspace(&workspace.workspace_id) .await? @@ -2601,7 +2766,7 @@ async fn resolve_server_actor( 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 + resolve_request_actor(api.store.clone(), headers, &cookie_name).await } fn signed_request_target(uri: &Uri) -> &str { @@ -2639,7 +2804,8 @@ fn repository_api_rejection(path: &str, status: StatusCode, message: &str) -> Re /// Claim-derived identities are emitted as claimed hashes. Method, path without query, /// and Workspace are request metadata with control-character and length limits. The generated /// request ID identifies this rejection, not a caller ID or cross-service trace. HTTP responses -/// remain generic 401s without proof details. Classification describes this verification result, +/// remain generic without proof details: rejection is 401, operational unavailability is 503. +/// Classification describes this verification result, /// not the cause or resolution of earlier intermittent 401s. fn runtime_request_proof_rejection( method: &str, @@ -2647,6 +2813,11 @@ fn runtime_request_proof_rejection( workspace_id: &str, error: &crate::worker_source::RuntimeRequestProofError, ) -> Response { + let status = if error.is_availability_error() { + StatusCode::SERVICE_UNAVAILABLE + } else { + StatusCode::UNAUTHORIZED + }; // Never format an error or raw claims. IDs are fixed-length fingerprints; // verification labels describe proof-check progress, not authorization. let path = path.split('?').next().unwrap_or(path); @@ -2658,7 +2829,7 @@ fn runtime_request_proof_rejection( target: "yoi::auth", event = "runtime_request_proof_rejected", method, path = log_path, workspace_id, request_id, - status = 401, + status = status.as_u16(), reason = error.reason, proof_verification = error.verification, claimed_runtime_id_hash = error.runtime_id_hash.as_deref(), @@ -2673,8 +2844,12 @@ fn runtime_request_proof_rejection( ); repository_api_rejection( path, - StatusCode::UNAUTHORIZED, - "invalid runtime request proof", + status, + if error.is_availability_error() { + "Runtime authentication authority unavailable" + } else { + "invalid runtime request proof" + }, ) } @@ -2749,7 +2924,7 @@ async fn authorize_scoped_workspace_request( worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION }; let source = crate::worker_source::verify_runtime_request_source_proof_with_clock( - api.store.as_ref(), + api.store.clone(), api.template.as_ref(), &proof, workspace_id, @@ -2840,7 +3015,9 @@ async fn authorize_workspace_api_request( mut request: Request, next: axum::middleware::Next, ) -> Response { - if !request.uri().path().starts_with("/api/") { + if !request.uri().path().starts_with("/api/") + && canonical_ticket_operation_workspace_id(request.uri().path()).is_none() + { return next.run(request).await; } let request_path = request.uri().path().to_owned(); @@ -2912,41 +3089,38 @@ async fn authorize_workspace_api_request( if !matches!( *request.method(), Method::GET | Method::HEAD | Method::OPTIONS - ) && !api - .store - .get_workspace(&workspace_id) - .await - .ok() - .flatten() - .is_some_and(|workspace| workspace.state == "active") - { - return repository_api_rejection( - &request_path, - StatusCode::CONFLICT, - "Workspace is deleting and no longer accepts mutations", - ); + ) { + let workspace = match api.store.get_workspace(&workspace_id).await { + Ok(workspace) => workspace, + Err(error) => return server_error_response(error), + }; + if !workspace.is_some_and(|workspace| workspace.state == "active") { + return repository_api_rejection( + &request_path, + StatusCode::CONFLICT, + "Workspace is deleting and no longer accepts mutations", + ); + } } return next.run(request).await; } let AuthConfig::Passkey { cookie_name, .. } = &api.config.auth; - let actor = match crate::auth::resolve_request_actor( - api.store.as_ref(), - request.headers(), - cookie_name, - ) - .await - { - Ok(Some(actor)) => actor, - Ok(None) if public_server_api => return next.run(request).await, - Ok(None) | Err(_) => { - return repository_api_rejection( - &request_path, - StatusCode::UNAUTHORIZED, - "authentication required", - ); - } - }; + let actor = + match crate::auth::resolve_request_actor(api.store.clone(), request.headers(), cookie_name) + .await + { + Ok(Some(actor)) => actor, + Ok(None) if public_server_api => return next.run(request).await, + Err(error) => return server_error_response(error), + Ok(None) => { + return repository_api_rejection( + &request_path, + StatusCode::UNAUTHORIZED, + "authentication required", + ); + } + }; let cookie_authenticated = matches!(actor.auth_method, ActorAuthMethod::BrowserSession); let mutating = !matches!( *request.method(), @@ -2969,20 +3143,18 @@ async fn authorize_workspace_api_request( } } request.extensions_mut().insert(actor); - if mutating - && !api - .store - .get_workspace(&workspace_id) - .await - .ok() - .flatten() - .is_some_and(|workspace| workspace.state == "active") - { - return repository_api_rejection( - &request_path, - StatusCode::CONFLICT, - "Workspace is deleting and no longer accepts mutations", - ); + if mutating { + let workspace = match api.store.get_workspace(&workspace_id).await { + Ok(workspace) => workspace, + Err(error) => return server_error_response(error), + }; + if !workspace.is_some_and(|workspace| workspace.state == "active") { + return repository_api_rejection( + &request_path, + StatusCode::CONFLICT, + "Workspace is deleting and no longer accepts mutations", + ); + } } next.run(request).await } @@ -3033,7 +3205,7 @@ fn workspace_request_is_callback(method: &Method, path: &str, workspace_id: &str async fn dispatch_workspace_request( State(api): State, - mut request: Request, + request: Request, ) -> Response { let path = request.uri().path().to_owned(); let workspace_id = scoped_workspace_id(&path); @@ -3059,8 +3231,27 @@ async fn dispatch_workspace_request( } else { None }; + if let Some(lease) = _request_lease { + crate::db_executor::with_operation_lifetime( + Arc::new(lease), + dispatch_admitted_workspace_request(api, request), + ) + .await + } else { + dispatch_admitted_workspace_request(api, request).await + } +} + +async fn dispatch_admitted_workspace_request( + api: WorkspaceServerApi, + mut request: Request, +) -> Response { + let path = request.uri().path().to_owned(); + let workspace_id = scoped_workspace_id(&path); if let Some(workspace_id) = workspace_id - && (path.starts_with("/api/w/") || path.starts_with("/api/runtime/v1/workspaces/")) + && (path.starts_with("/api/w/") + || path.starts_with("/api/runtime/v1/workspaces/") + || canonical_ticket_operation_workspace_id(&path).is_some()) && let Err(response) = authorize_scoped_workspace_request(&api, workspace_id, &mut request).await { @@ -3073,7 +3264,13 @@ async fn dispatch_workspace_request( Err(error) => return server_error_response(error), } } else { - let workspaces = match api.store.list_workspaces() { + let store = api.store.clone(); + let workspaces = match api + .store + .db_executor() + .run(move || store.list_workspaces()) + .await + { Ok(workspaces) => workspaces, Err(error) => return server_error_response(error), }; @@ -3124,7 +3321,9 @@ fn is_server_global_forward(path: &str) -> bool { } fn is_server_static_forward(path: &str) -> bool { - !path.starts_with("/api/") && !path.starts_with("/internal/") + !path.starts_with("/api/") + && !path.starts_with("/internal/") + && canonical_ticket_operation_workspace_id(path).is_none() } async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Response { @@ -3134,7 +3333,33 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp static_file_or_spa_response(static_root, path).await } +// Canonical Ticket operation routes are deliberately not /api/w aliases. Recognize +// only these operations so authentication/scope are identical without exposing CRUD. +fn canonical_ticket_operation_workspace_id(path: &str) -> Option<&str> { + let mut segments = path.strip_prefix("/workspaces/")?.split('/'); + let workspace_id = segments.next()?; + let resource = segments.next()?; + let ticket_id = segments.next()?; + let operation = segments.next()?; + (!workspace_id.is_empty() + && resource == "tickets" + && !ticket_id.is_empty() + && match operation { + "case" | "assignment-diagnostics" | "assignment-answer" => segments.next().is_none(), + "assignment-tracking" => match segments.next() { + None => true, + Some("history" | "assignments") => segments.next().is_none(), + _ => false, + }, + _ => false, + }) + .then_some(workspace_id) +} + fn scoped_workspace_id(path: &str) -> Option<&str> { + if let Some(workspace_id) = canonical_ticket_operation_workspace_id(path) { + return Some(workspace_id); + } if let Some(rest) = path.strip_prefix("/api/runtime/v1/workspaces/") { return rest .split('/') @@ -3321,6 +3546,19 @@ impl WorkspaceApi { } pub async fn new(config: ServerConfig, store: Arc) -> Result { + Self::new_with_host_executors(config, store, HostExecutors::shared()).await + } + + #[cfg(test)] + async fn new_for_test(config: ServerConfig, store: Arc) -> Result { + Self::new_with_host_executors(config, store, HostExecutors::default()).await + } + + async fn new_with_host_executors( + config: ServerConfig, + store: Arc, + host_executors: HostExecutors, + ) -> Result { let resource_broker = BackendResourceBroker::default(); if store.get_workspace(&config.workspace_id).await?.is_none() { return Err(crate::Error::Config(format!( @@ -3397,6 +3635,7 @@ impl WorkspaceApi { store, Arc::new(execution_backend), resource_broker, + host_executors, Some(worker_remove_dispatcher), ) .await?; @@ -3457,6 +3696,7 @@ impl WorkspaceApi { store, execution_backend, BackendResourceBroker::default(), + HostExecutors::default(), None, ) .await @@ -3467,6 +3707,7 @@ impl WorkspaceApi { store: Arc, execution_backend: Arc, resource_broker: BackendResourceBroker, + host_executors: HostExecutors, worker_remove_dispatcher: Option< Arc, >, @@ -3477,8 +3718,18 @@ impl WorkspaceApi { config.workspace_id ))); } - import_configured_repositories(store.as_ref(), &config)?; - config.repositories = load_configured_repositories_from_store(store.as_ref(), &config)?; + let repository_store = store.clone(); + let repository_config = config.clone(); + config.repositories = store + .db_executor() + .run(move || { + import_configured_repositories(repository_store.as_ref(), &repository_config)?; + load_configured_repositories_from_store( + repository_store.as_ref(), + &repository_config, + ) + }) + .await?; let embedded_runtime = EmbeddedWorkerRuntime::new_fs_store_with_execution_backend( config.workspace_id.clone(), config.embedded_runtime_store_root.clone(), @@ -3493,20 +3744,34 @@ impl WorkspaceApi { RuntimeSubscriptionBroker::new(config.workspace_id.clone()); runtime_subscription_broker .register_embedded_runtime(embedded_runtime_id, embedded_subscription_runtime); - let config_store = Arc::new(crate::SqliteWorkspaceStore::open( - config.database_path.clone(), - )?); - let repository_secrets = Arc::new(RepositorySecretService::open( - config_store.clone(), - &config.database_path, - )?); + let database_path = config.database_path.clone(); + let executor = store.db_executor(); + let config_executor = executor.clone(); + let (config_store, repository_secrets) = executor + .run(move || { + let config_store = Arc::new( + crate::SqliteWorkspaceStore::open(database_path.clone())? + .with_db_executor(config_executor), + ); + let repository_secrets = Arc::new(RepositorySecretService::open( + config_store.clone(), + &database_path, + )?); + Ok::<_, Error>((config_store, repository_secrets)) + }) + .await?; let signing_materials: Arc = Arc::new(FsWorkspaceSigningMaterialStore::new( workspace_signing_material_root(&config.database_path), )); let signing_identities = WorkspaceSigningIdentityService::new(store.clone(), signing_materials); - signing_identities.get_validated(&config.workspace_id)?; + let identities = signing_identities.clone(); + let workspace_id = config.workspace_id.clone(); + store + .db_executor() + .run(move || identities.get_validated(&workspace_id)) + .await?; let backend_url = config .backend_base_url .clone() @@ -3549,11 +3814,19 @@ impl WorkspaceApi { .with_provider(Arc::new(RuntimeConfigSchemaProvider)) .with_provider(Arc::new(RepositoryAccessConfigSchemaProvider)) .with_provider(Arc::new(skills::SkillConfigSchemaProvider)); - config_store.ensure_workspace_config_materialized_with_schema( - &config.workspace_id, - &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - config_schema_registry.compose()?, - )?; + let materialization_store = config_store.clone(); + let workspace_id = config.workspace_id.clone(); + let schema = config_schema_registry.compose()?; + store + .db_executor() + .run(move || { + materialization_store.ensure_workspace_config_materialized_with_schema( + &workspace_id, + &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + schema, + ) + }) + .await?; let worker_projection = crate::worker_projection::WorkerProjectionService::new( config.workspace_id.clone(), store.clone(), @@ -3564,18 +3837,28 @@ impl WorkspaceApi { .await .map_err(|error| Error::InvalidInput(error.to_string()))?; } - let feature_storage = crate::FeatureStorage::for_server_database(&config.database_path) - .and_then(|storage| storage.workspace(&config.workspace_id)) - .map_err(|error| { - Error::Store(format!("failed to initialize Feature storage: {error}")) - })?; - let subjektiv_registration = crate::subjektiv::SubjektivStore::register(&feature_storage) - .map_err(|error| { - Error::Store(format!("failed to register subjektiv storage: {error}")) - })?; + let database_path = config.database_path.clone(); + let workspace_id = config.workspace_id.clone(); + let (feature_storage, subjektiv_registration, authority) = store + .db_executor() + .run(move || { + let feature_storage = crate::FeatureStorage::for_server_database(&database_path) + .and_then(|storage| storage.workspace(&workspace_id)) + .map_err(|error| { + Error::Store(format!("failed to initialize Feature storage: {error}")) + })?; + let registration = crate::subjektiv::SubjektivStore::register(&feature_storage) + .map_err(|error| { + Error::Store(format!("failed to register subjektiv storage: {error}")) + })?; + let authority = SqliteWorkspaceAuthority::new(database_path, workspace_id)?; + Ok::<_, Error>((feature_storage, registration, authority)) + }) + .await?; let drive_host = Arc::new(crate::workspace_drive_host::WorkspaceDriveHost::new(&feature_storage).await?); let mut api = Self { + host_executors, drive_host, config_store, repository_secrets, @@ -3585,10 +3868,7 @@ impl WorkspaceApi { crate::prompt_settings::WorkspacePromptProjectionCache::default(), feature_storage, subjektiv_registration, - authority: SqliteWorkspaceAuthority::new( - config.database_path.clone(), - config.workspace_id.clone(), - )?, + authority, _worker_projection_shutdown: Arc::new(WorkerProjectionShutdownGuard { service: Arc::downgrade(&worker_projection), }), @@ -3638,59 +3918,88 @@ impl WorkspaceApi { )) .map_err(|message| Error::Config(message.to_string()))?; } - let expired_external_workdirs = api.store.reconcile_external_workdir_grants_after_restart( - &api.config.workspace_id, - &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), - )?; + let recovery_api = api.clone(); + let expired_external_workdirs = api + .store + .db_executor() + .run(move || { + recovery_api + .store + .reconcile_external_workdir_grants_after_restart( + &recovery_api.config.workspace_id, + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) + }) + .await?; for workdir_id in expired_external_workdirs { cleanup_external_workdir_attachments(&api, &workdir_id) .await .map_err(|error| error.error)?; } - for grant in api + let grant_api = api.clone(); + let grants = api .store - .list_external_workdir_grants(&api.config.workspace_id)? + .db_executor() + .run(move || { + grant_api + .store + .list_external_workdir_grants(&grant_api.config.workspace_id) + }) + .await?; + for grant in grants .into_iter() .filter(|grant| !matches!(grant.status.as_str(), "revoked" | "expired")) { schedule_external_workdir_expiry(&api, &grant)?; } - recover_workdir_removals(&api)?; + let workdir_recovery_api = api.clone(); + api.host_executors + .host()? + .run(move || recover_workdir_removals(&workdir_recovery_api)) + .await?; recover_runtime_removals(&api).await; - let recovery_now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); - let deliveries = api - .store - .recover_backend_job_deliveries(&api.config.workspace_id, &recovery_now)?; - for delivery in deliveries { - let Some(job) = api - .store - .get_backend_job(&api.config.workspace_id, &delivery.job_id)? - else { - continue; - }; - let Some(attempt) = api.store.get_backend_job_attempt( - &api.config.workspace_id, - &delivery.job_id, - &delivery.attempt_id, - )? - else { - continue; - }; - if job.state == BackendJobState::Completed - && attempt.state == BackendJobAttemptState::Completed - { - api.deliver_backend_job_result( - &BackendJobResultAcceptance { - job, - attempt, - replayed: true, - }, - &delivery.target, - ); - } - } - api.recover_backend_jobs()?; - Ok(api) + api.host_executors + .host()? + .run(move || { + let recovery_now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); + let deliveries = api + .store + .recover_backend_job_deliveries(&api.config.workspace_id, &recovery_now)?; + for delivery in deliveries { + let Some(job) = api + .store + .get_backend_job(&api.config.workspace_id, &delivery.job_id)? + else { + continue; + }; + let Some(attempt) = api.store.get_backend_job_attempt( + &api.config.workspace_id, + &delivery.job_id, + &delivery.attempt_id, + )? + else { + continue; + }; + if job.state == BackendJobState::Completed + && attempt.state == BackendJobAttemptState::Completed + { + api.deliver_backend_job_result( + &BackendJobResultAcceptance { + job, + attempt, + replayed: true, + }, + &delivery.target, + ); + } + } + api.recover_backend_jobs()?; + if let Some(orchestrator) = find_online_workspace_orchestrator(&api) { + dispatch_orchestrator_assignment_attention(&api, &orchestrator.worker); + } + Ok(api) + }) + .await } pub fn workspace_id(&self) -> &str { @@ -4073,6 +4382,9 @@ impl WorkspaceApi { Ok(attempt) } + /// Admit on the independent cleanup lane before claiming durable work. Rejection leaves + /// Pending/Failed recoverable, not an orphaned Executing claim. Runtime teardown is a + /// lifecycle fence, not model-processing evidence; unknown DB outcomes are not retried. fn schedule_backend_job_worker_cleanup(&self, attempt: &BackendJobAttemptRecord) -> bool { if !attempt.state.terminal() || attempt.worker.is_none() @@ -4083,77 +4395,44 @@ impl WorkspaceApi { { return false; } - let Ok(handle) = tokio::runtime::Handle::try_current() else { - tracing::warn!( - job_id = %attempt.job_id, - attempt_id = %attempt.attempt_id, - "Backend Job Worker cleanup remains durable because no Tokio runtime is available" - ); + let Ok(runtime) = tokio::runtime::Handle::try_current() else { + tracing::warn!(job_id = %attempt.job_id, "Backend Job cleanup remains durable without a runtime"); return false; }; - let (attempt, claimed) = match self.store.claim_backend_job_worker_cleanup( - &self.config.workspace_id, - &attempt.job_id, - &attempt.attempt_id, - &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), - ) { - Ok(claim) => claim, - Err(error) => { - tracing::warn!( - %error, - job_id = %attempt.job_id, - attempt_id = %attempt.attempt_id, - "failed to claim durable Backend Job Worker cleanup" - ); - return false; - } - }; - if !claimed { - return false; - } - let waits_for_run_teardown = attempt.state == BackendJobAttemptState::Completed - || attempt.failure_category.as_deref() == Some("invalid_result"); - if waits_for_run_teardown { - let api = self.clone(); - let cleanup_handle = handle.clone(); - handle.spawn(async move { - if let Err(error) = api.wait_for_backend_job_worker_run_teardown(&attempt).await { - tracing::warn!( - %error, - job_id = %attempt.job_id, - attempt_id = %attempt.attempt_id, - "Backend Job Worker Run teardown fence failed" - ); - return; - } - api.spawn_claimed_backend_job_worker_cleanup(&cleanup_handle, attempt); - }); - } else { - self.spawn_claimed_backend_job_worker_cleanup(&handle, attempt); - } - true - } - - fn spawn_claimed_backend_job_worker_cleanup( - &self, - handle: &tokio::runtime::Handle, - attempt: BackendJobAttemptRecord, - ) { let api = self.clone(); let job_id = attempt.job_id.clone(); let attempt_id = attempt.attempt_id.clone(); - handle.spawn_blocking(move || { + // Admit before claiming: overload must leave durable Pending/Failed + // ownership recoverable, never strand a Running claim without a job. + let admission = self.host_executors.cleanup().and_then(|executor| { + executor.dispatch(move || { let cleanup = || -> Result<()> { - tokio::runtime::Builder::new_current_thread() - .enable_all() - .build() - .map_err(|error| Error::Store(error.to_string()))? - .block_on(api.cleanup_backend_job_attempt_worker(attempt)) + let (attempt, claimed) = api.store.claim_backend_job_worker_cleanup( + &api.config.workspace_id, &job_id, &attempt_id, + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + )?; + if !claimed { return Ok(()); } + let waits_for_run_teardown = attempt.state == BackendJobAttemptState::Completed + || attempt.failure_category.as_deref() == Some("invalid_result"); + runtime.block_on(async { + if waits_for_run_teardown { + api.wait_for_backend_job_worker_run_teardown(&attempt).await?; + } + // Already on the Host owner; removal must not submit to and + // wait on its own lane. All transaction helpers return before I/O. + api.cleanup_backend_job_attempt_worker(attempt).await + }) }; if let Err(error) = cleanup() { tracing::warn!(%error, %job_id, %attempt_id, "Backend Job Worker cleanup failed"); } + }) }); + if let Err(error) = admission { + tracing::warn!(%error, "Backend Job cleanup was not admitted; its durable ownership is unchanged"); + return false; + } + true } async fn wait_for_backend_job_worker_run_teardown( @@ -4288,7 +4567,7 @@ impl WorkspaceApi { // recover that midpoint; without such an operation this remains // a diagnosable retryable failure rather than a direct DB delete. let response = WorkerRemovalService::new(self) - .execute_target_removal( + .execute_target_removal_on_host( self.runtime.as_ref(), &worker, &format!("Terminal Backend Job attempt {attempt_id}"), @@ -4354,7 +4633,7 @@ impl WorkspaceApi { } let response = WorkerRemovalService::new(self) - .execute_target_removal( + .execute_target_removal_on_host( self.runtime.as_ref(), &worker, &format!("Terminal Backend Job attempt {attempt_id}"), @@ -4481,6 +4760,7 @@ impl WorkspaceApi { let attempt_id = attempt_id.to_string(); handle.spawn(async move { tokio::time::sleep(delay).await; + let admission = api.host_executors.host().and_then(|executor| executor.dispatch(move || { let Ok(Some(attempt)) = api.store .get_backend_job_attempt(&api.config.workspace_id, &job_id, &attempt_id) @@ -4504,6 +4784,10 @@ impl WorkspaceApi { { api.drain_reserved_backend_jobs(); } + })); + if let Err(error) = admission { + tracing::warn!(%error, "Backend Job timeout was not admitted; its deadline remains durable"); + } }); } @@ -6235,7 +6519,7 @@ async fn attach_server_request_context( } else { let auth = service.auth_api(); match resolve_request_actor( - auth.store.as_ref(), + auth.store.clone(), request.headers(), &auth_public_config(&auth.config).cookie_name, ) @@ -6256,6 +6540,7 @@ async fn attach_server_request_context( .extensions() .get::() .map(|source| server_api::ServerRuntimeSource { + authentication_binding_id: source.authentication_binding_id.clone(), runtime_id: source.runtime_id.clone(), worker_id: source.worker_id.clone(), }); @@ -6582,21 +6867,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::ticket_thread_event_add( service.clone(), )) - .merge(server_api::server_api_axum::ticket_state_change_add( - service.clone(), - )) .merge(server_api::server_api_axum::ticket_intake_summary_add( service.clone(), )) - .merge(server_api::server_api_axum::ticket_state_field_set( - service.clone(), - )) - .merge(server_api::server_api_axum::ticket_workflow_state_set( - service.clone(), - )) - .merge(server_api::server_api_axum::ticket_state_update( - service.clone(), - )) .merge(server_api::server_api_axum::ticket_mark_ready_record( service.clone(), )) @@ -6634,10 +6907,33 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::merge_request_complete( service.clone(), )) - .merge(server_api::server_api_axum::ticket_complete( + .merge(server_api::server_api_axum::ticket_case_update( service.clone(), )) - .merge(server_api::server_api_axum::ticket_close_record( + .merge(server_api::server_api_axum::ticket_assignments_page( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignments_page_browser(service.clone())) + .merge(server_api::server_api_axum::ticket_assignment_diagnostics( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignment_diagnostics_browser(service.clone())) + .merge(server_api::server_api_axum::ticket_assignment_answer( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignment_answer_browser(service.clone())) + .merge(server_api::server_api_axum::ticket_assignment_tracking_get( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_assignment_tracking_update(service.clone())) + .merge(server_api::server_api_axum::ticket_assignment_tracking_history(service.clone())) + .merge(server_api::server_api_axum::workspace_ticket_tag_list( + service.clone(), + )) + .merge(server_api::server_api_axum::workspace_ticket_tag_mutate( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_tags_set( service.clone(), )) .merge(server_api::server_api_axum::ticket_relation_view( @@ -6665,15 +6961,11 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::ticket_implementation_cancel( service.clone(), )) - .merge(server_api::server_api_axum::ticket_state_transition( - service.clone(), - )) .merge(server_api::server_api_axum::ticket_ready(service.clone())) .merge(server_api::server_api_axum::ticket_event_append( service.clone(), )) .merge(server_api::server_api_axum::ticket_queue(service.clone())) - .merge(server_api::server_api_axum::ticket_close(service.clone())) .merge(server_api::server_api_axum::workspace_worker_list_alias( service.clone(), )) @@ -6885,6 +7177,7 @@ fn worker_ticket_assignment_from_api( assignment: server_api::RuntimeWorkerTicketAssignmentRequest, ) -> WorkerTicketAssignmentRequest { WorkerTicketAssignmentRequest { + orchestrator_assignment_id: assignment.orchestrator_assignment_id, ticket_id: assignment.ticket_id, operation_id: assignment.operation_id, } @@ -7191,7 +7484,12 @@ impl server_api::ServerApi for ServerApiContractService { server_api::server_api_responses::AuthPasskeyRegistrationComplete, server_api::RepositoryApiError, > { - let (body, header_set_cookie) = complete_passkey_registration(&self.auth_api(), request) + let api = self.auth_api(); + let (body, header_set_cookie) = api + .store + .db_executor() + .run(move || complete_passkey_registration(&api, request)) + .await .map_err(ApiError::into_repository_api_error)?; Ok( server_api::server_api_responses::AuthPasskeyRegistrationComplete::Status200 { @@ -7222,7 +7520,12 @@ impl server_api::ServerApi for ServerApiContractService { server_api::server_api_responses::AuthPasskeyLoginComplete, server_api::RepositoryApiError, > { - let (body, header_set_cookie) = complete_passkey_login(&self.auth_api(), request) + let api = self.auth_api(); + let (body, header_set_cookie) = api + .store + .db_executor() + .run(move || complete_passkey_login(&api, request)) + .await .map_err(ApiError::into_repository_api_error)?; Ok( server_api::server_api_responses::AuthPasskeyLoginComplete::Status200 { @@ -7255,7 +7558,11 @@ impl server_api::ServerApi for ServerApiContractService { Vec::new(), ) })?; - approve_device_login(&self.auth_api(), actor, request) + let api = self.auth_api(); + api.store + .db_executor() + .run(move || approve_device_login(&api, actor, request)) + .await .map_err(ApiError::into_repository_api_error) } @@ -7285,7 +7592,12 @@ impl server_api::ServerApi for ServerApiContractService { server_api::server_api_responses::AuthLogout, server_api::RepositoryApiError, > { - let (body, header_set_cookie) = logout_browser_session(&self.auth_api(), cookie) + let api = self.auth_api(); + let (body, header_set_cookie) = api + .store + .db_executor() + .run(move || logout_browser_session(&api, cookie)) + .await .map_err(ApiError::into_repository_api_error)?; Ok(server_api::server_api_responses::AuthLogout::Status200 { body, @@ -7300,9 +7612,12 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.server_api()?; let Some(actor) = context.actor else { + let catalog = api.catalog.clone(); return if api - .catalog - .is_empty() + .store + .db_executor() + .run(move || catalog.is_empty()) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())? { Ok(WorkspaceCatalogListResponse(Vec::new())) @@ -7315,12 +7630,17 @@ impl server_api::ServerApi for ServerApiContractService { )) }; }; + let catalog = api.catalog.clone(); let workspaces = api - .catalog - .list( - &actor.account_id, - query.limit.unwrap_or(100).try_into().unwrap_or(200), - ) + .store + .db_executor() + .run(move || { + catalog.list( + &actor.account_id, + query.limit.unwrap_or(100).try_into().unwrap_or(200), + ) + }) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; Ok(WorkspaceCatalogListResponse( workspaces.into_iter().map(workspace_summary).collect(), @@ -7341,9 +7661,12 @@ impl server_api::ServerApi for ServerApiContractService { Vec::new(), ) })?; + let catalog = api.catalog.clone(); let created = api - .catalog - .create(request, actor.account_id) + .store + .db_executor() + .run(move || catalog.create(request, actor.account_id)) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; api.router_for_workspace(&created.workspace.workspace_id) .await @@ -7388,10 +7711,14 @@ impl server_api::ServerApi for ServerApiContractService { ) })?; let admission_for_workspace = api.admission_for_workspace(&workspace_id).await; - let _mutation_guard = admission_for_workspace.lock_deletion().await; + let mutation_guard = Arc::new(admission_for_workspace.lock_deletion().await); + let account_id = actor.account_id.clone(); + let operation_id = request.operation_id.clone(); let existing = api - .store - .workspace_deletion_operation(&actor.account_id, &request.operation_id) + .execute_store_stage(move |store| { + store.workspace_deletion_operation(&account_id, &operation_id) + }) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; if existing.is_none() { let preflight = api @@ -7407,9 +7734,18 @@ impl server_api::ServerApi for ServerApiContractService { )); } } + let account_id = actor.account_id.clone(); + let id = workspace_id.clone(); + let reserve_request = request.clone(); + let reserve_guard = mutation_guard.clone(); let reservation = api - .store - .reserve_workspace_deletion(&actor.account_id, &workspace_id, &request) + .execute_store_stage(move |store| { + // Keep deletion closed until the accepted reservation really finishes, + // even when the HTTP task drops its original guard. + let _guard = reserve_guard; + store.reserve_workspace_deletion(&account_id, &id, &reserve_request) + }) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; if let Some(handle) = api.hook_handles.lock().await.remove(&workspace_id) { handle.abort(); @@ -7449,17 +7785,19 @@ impl server_api::ServerApi for ServerApiContractService { Vec::new(), ) })?; - api.store - .workspace_deletion_operation(&actor.account_id, &operation_id) - .map_err(|error| ApiError::from(error).into_repository_api_error())? - .ok_or_else(|| { - server_api::RepositoryApiError::new( - StatusCode::NOT_FOUND.as_u16(), - "Not Found", - "Workspace deletion operation not found", - Vec::new(), - ) - }) + api.execute_store_stage(move |store| { + store.workspace_deletion_operation(&actor.account_id, &operation_id) + }) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error())? + .ok_or_else(|| { + server_api::RepositoryApiError::new( + StatusCode::NOT_FOUND.as_u16(), + "Not Found", + "Workspace deletion operation not found", + Vec::new(), + ) + }) } async fn workspace_current( @@ -7494,13 +7832,20 @@ impl server_api::ServerApi for ServerApiContractService { server_api::server_api_responses::WorkspaceRuntimeConfig, server_api::RepositoryApiError, > { - workspace_runtime_config_result( - self.workspace_api()?, - context, - &workspace_id, - query, - if_none_match, - ) + let api = self.workspace_api()?.clone(); + api.store + .db_executor() + .run(move || { + Ok::<_, Error>(workspace_runtime_config_result( + &api, + context, + &workspace_id, + query, + if_none_match, + )) + }) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error())? } async fn workspace_metadata_settings( @@ -8425,16 +8770,28 @@ impl server_api::ServerApi for ServerApiContractService { )); } let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone()); - let observation = api - .runtime - .worker_session(&workspace_id, &worker) - .map_err(|_error| { - runtime_api::RuntimeApiError::new( - StatusCode::BAD_GATEWAY.as_u16(), - "worker_session_observation_failed", - "Worker Session observation failed", + let operation_api = api.clone(); + let operation_workspace = workspace_id.clone(); + let runtime_result = api + .host_executors + .host() + .map_err(runtime_execution_error)? + .run(move || { + Ok::<_, Error>( + operation_api + .runtime + .worker_session(&operation_workspace, &worker), ) - })?; + }) + .await + .map_err(runtime_execution_error)?; + let observation = runtime_result.map_err(|_error| { + runtime_api::RuntimeApiError::new( + StatusCode::BAD_GATEWAY.as_u16(), + "worker_session_observation_failed", + "Worker Session observation failed", + ) + })?; Ok(server_api::WorkspaceWorkerSessionResponse { subject: server_api::WorkspaceWorkerSubject::RuntimeWorker { runtime_id, @@ -8469,16 +8826,29 @@ impl server_api::ServerApi for ServerApiContractService { )); } let worker = RuntimeWorkerRef::new(runtime_id.clone(), worker_id.clone()); - let observation = api - .runtime - .worker_session_history(&workspace_id, &worker, query.cursor, query.limit) - .map_err(|_error| { - runtime_api::RuntimeApiError::new( - StatusCode::BAD_GATEWAY.as_u16(), - "worker_session_history_failed", - "Worker Session history request failed", - ) - })?; + let operation_api = api.clone(); + let operation_workspace = workspace_id.clone(); + let runtime_result = api + .host_executors + .host() + .map_err(runtime_execution_error)? + .run(move || { + Ok::<_, Error>(operation_api.runtime.worker_session_history( + &operation_workspace, + &worker, + query.cursor, + query.limit, + )) + }) + .await + .map_err(runtime_execution_error)?; + let observation = runtime_result.map_err(|_error| { + runtime_api::RuntimeApiError::new( + StatusCode::BAD_GATEWAY.as_u16(), + "worker_session_history_failed", + "Worker Session history request failed", + ) + })?; Ok(server_api::WorkspaceWorkerSessionHistoryResponse { subject: server_api::WorkspaceWorkerSubject::RuntimeWorker { runtime_id, @@ -8503,10 +8873,12 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::CreateWorkspaceWorkerRequest, ) -> std::result::Result { - create_workspace_worker( - State(self.workspace_api()?.clone()), + create_workspace_worker_authenticated( + self.workspace_api()?.clone(), contract_request_headers(&context)?, - Json(request), + request, + None, + Some(&context), ) .await .map(|Json(response)| response) @@ -8568,11 +8940,12 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::CreateWorkspaceWorkerRequest, ) -> std::result::Result { - spawn_known_worker( + spawn_known_worker_authenticated( State(self.workspace_api()?.clone()), AxumPath(ScopedWorkspacePath { workspace_id }), current_worker_contract_headers(&context)?, Json(request), + Some(&context), ) .await .map(|Json(response)| response) @@ -8746,11 +9119,14 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::CreateWorkspaceWorkerRequest, ) -> std::result::Result { - scoped_create_workspace_worker( - State(self.workspace_api()?.clone()), - AxumPath(ScopedWorkspacePath { workspace_id }), + validate_workspace_scope(self.workspace_api()?, &workspace_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + create_workspace_worker_authenticated( + self.workspace_api()?.clone(), contract_request_headers(&context)?, - Json(request), + request, + None, + Some(&context), ) .await .map(|Json(response)| response) @@ -9114,62 +9490,70 @@ impl server_api::ServerApi for ServerApiContractService { "expected_binding_id must contain between 1 and 256 bytes", ))); } - let now = Utc::now().to_rfc3339(); - if let Err(error) = api - .store - .revoke_workspace_runtime_binding_key( - &workspace_id, - &runtime_id, - &request.expected_binding_id, - &actor.account_id, - &now, - ) - .await - { - let kind = match error { - Error::RuntimeBindingIdConflict { .. } => { - Some(server_api::RuntimeTrustConflictKind::StaleBinding) - } - Error::RuntimeBindingFingerprintConflict { .. } => { - Some(server_api::RuntimeTrustConflictKind::FingerprintInUse) - } - _ => None, - }; - if let Some(kind) = kind { - let current = api - .store - .get_workspace_runtime_binding(&workspace_id, &runtime_id) - .await - .map_err(|error| map_error(ApiError::from(error)))?; - return Err(server_api::RuntimeManagementApiError::trust_conflict( - server_api::RuntimeTrustConflictResponse { - error: kind, - message: match kind { - server_api::RuntimeTrustConflictKind::StaleBinding => { - "the Runtime trust binding changed; reload before retrying".to_string() - } - server_api::RuntimeTrustConflictKind::FingerprintInUse => { - "the public key is already bound to another Runtime in this Workspace".to_string() - } + // Own the whole revoke outcome through local publication and response. + // The accepted DB mutation outlives a cancelled HTTP waiter; its Host + // parent must also remove expectations and terminate subscriptions. + // SQL keeps its independent owner, and response reads stay on this Host + // workflow rather than attempting nested admission to the same lane. + run_host_workflow(api.host_executors.host()?, async move { + let now = Utc::now().to_rfc3339(); + if let Err(error) = api + .store + .revoke_workspace_runtime_binding_key( + &workspace_id, + &runtime_id, + &request.expected_binding_id, + &actor.account_id, + &now, + ) + .await + { + let kind = match error { + Error::RuntimeBindingIdConflict { .. } => { + Some(server_api::RuntimeTrustConflictKind::StaleBinding) + } + Error::RuntimeBindingFingerprintConflict { .. } => { + Some(server_api::RuntimeTrustConflictKind::FingerprintInUse) + } + _ => None, + }; + if let Some(kind) = kind { + let current = api + .store + .get_workspace_runtime_binding(&workspace_id, &runtime_id) + .await + .map_err(|error| map_error(ApiError::from(error)))?; + return Err(server_api::RuntimeManagementApiError::trust_conflict( + server_api::RuntimeTrustConflictResponse { + error: kind, + message: match kind { + server_api::RuntimeTrustConflictKind::StaleBinding => { + "the Runtime trust binding changed; reload before retrying".to_string() + } + server_api::RuntimeTrustConflictKind::FingerprintInUse => { + "the public key is already bound to another Runtime in this Workspace".to_string() + } + }, + current_binding_id: current.as_ref().map(|binding| binding.binding_id.clone()), + current_fingerprint: current + .as_ref() + .map(|binding| binding.public_key_fingerprint.clone()), }, - current_binding_id: current.as_ref().map(|binding| binding.binding_id.clone()), - current_fingerprint: current - .as_ref() - .map(|binding| binding.public_key_fingerprint.clone()), - }, - )); + )); + } + return Err(map_error(ApiError::from(error))); } - return Err(map_error(ApiError::from(error))); - } - api.runtime_binding_expectations - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .remove(&(workspace_id.clone(), runtime_id.clone())); - api.runtime_subscription_broker - .unregister_runtime(&runtime_id); - workspace_runtime_detail(&api, &workspace_id, &runtime_id) - .await - .map_err(map_error) + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .remove(&(workspace_id.clone(), runtime_id.clone())); + api.runtime_subscription_broker + .unregister_runtime(&runtime_id); + workspace_runtime_detail_on_host(&api, &workspace_id, &runtime_id) + .await + .map_err(map_error) + }) + .await } async fn runtime_connection_test( @@ -9212,14 +9596,16 @@ impl server_api::ServerApi for ServerApiContractService { async fn runtime_worker_create_alias( &self, + context: server_api::ServerRequestContext, runtime_id: String, request: server_api::RuntimeWorkerSpawnRequest, ) -> std::result::Result { - let Json(response) = create_runtime_worker( + let Json(response) = create_runtime_worker_authenticated( State(self.workspace_api()?.clone()), AxumPath(runtime_id), Json(request), + Some(&context), ) .await .map_err(ApiError::into_repository_api_error)?; @@ -9248,18 +9634,19 @@ impl server_api::ServerApi for ServerApiContractService { async fn runtime_worker_create( &self, + context: server_api::ServerRequestContext, workspace_id: String, runtime_id: String, request: server_api::RuntimeWorkerSpawnRequest, ) -> std::result::Result { - let Json(response) = scoped_create_runtime_worker( + validate_workspace_scope(self.workspace_api()?, &workspace_id) + .map_err(|error| ApiError::from(error).into_repository_api_error())?; + let Json(response) = create_runtime_worker_authenticated( State(self.workspace_api()?.clone()), - AxumPath(ScopedRuntimePath { - workspace_id, - runtime_id, - }), + AxumPath(runtime_id), Json(request), + Some(&context), ) .await .map_err(ApiError::into_repository_api_error)?; @@ -9384,7 +9771,7 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; restore_runtime_worker_with_context(api, runtime_id, worker_id, operation_context, request) .await .map(|Json(response)| response) @@ -9402,7 +9789,7 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; validate_workspace_scope(&api, &workspace_id) .map_err(ApiError::into_repository_api_error)?; scoped_restore_runtime_worker_with_context( @@ -9429,10 +9816,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; validate_workspace_scope(&api, &workspace_id) .map_err(ApiError::into_repository_api_error)?; - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id) + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; worker .set_pinned(&operation_context, true) @@ -9449,10 +9837,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; validate_workspace_scope(&api, &workspace_id) .map_err(ApiError::into_repository_api_error)?; - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id) + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; worker .set_pinned(&operation_context, false) @@ -9489,7 +9878,7 @@ impl server_api::ServerApi for ServerApiContractService { server_api::RepositoryApiError, > { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; validate_workspace_scope(&api, &workspace_id) .map_err(ApiError::into_repository_api_error)?; execute_runtime_cleanup_with_context(&api, &runtime_id, request, &operation_context) @@ -9506,8 +9895,9 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id) + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; let request = worker_input_request_from_api(request) .map_err(|error| ApiError::from(error).into_repository_api_error())?; @@ -9528,10 +9918,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; validate_workspace_scope(&api, &workspace_id) .map_err(ApiError::into_repository_api_error)?; - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id) + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; let request = worker_input_request_from_api(request) .map_err(|error| ApiError::from(error).into_repository_api_error())?; @@ -9691,8 +10082,9 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id) + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; worker .stop( @@ -9714,10 +10106,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; validate_workspace_scope(&api, &workspace_id) .map_err(ApiError::into_repository_api_error)?; - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id) + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; worker .stop( @@ -9738,8 +10131,9 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id) + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; worker .cancel( @@ -9761,10 +10155,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result { let api = self.workspace_api()?.clone(); - let operation_context = WorkerOperationContext::from_request(&api, &context)?; + let operation_context = WorkerOperationContext::from_request_async(&api, &context).await?; validate_workspace_scope(&api, &workspace_id) .map_err(ApiError::into_repository_api_error)?; - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id) + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id) + .await .map_err(|error| ApiError::from(error).into_repository_api_error())?; worker .cancel( @@ -10085,24 +10480,6 @@ impl server_api::ServerApi for ServerApiContractService { Ok(()) } - async fn ticket_state_change_add( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::TicketStateChangeRequest, - ) -> std::result::Result<(), server_api::RepositoryApiError> { - scoped_add_ticket_state_change( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(()) - } - async fn ticket_intake_summary_add( &self, context: server_api::ServerRequestContext, @@ -10121,43 +10498,6 @@ impl server_api::ServerApi for ServerApiContractService { Ok(()) } - async fn ticket_state_field_set( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - field: String, - request: server_api::TicketStateChangeRequest, - ) -> std::result::Result<(), server_api::RepositoryApiError> { - scoped_set_ticket_state_field( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id, field)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(()) - } - - async fn ticket_workflow_state_set( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::TicketStateChangeRequest, - ) -> std::result::Result<(), server_api::RepositoryApiError> { - scoped_set_ticket_workflow_state( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(()) - } - async fn ticket_mark_ready_record( &self, context: server_api::ServerRequestContext, @@ -10386,58 +10726,124 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } - async fn ticket_complete( + async fn ticket_case_update( &self, context: server_api::ServerRequestContext, workspace_id: String, - id: String, - request: server_api::CompleteTicketRequest, - ) -> std::result::Result { - let Json(response) = scoped_complete_ticket( + ticket_id: String, + request: server_api::TicketCaseUpdateRequest, + ) -> std::result::Result { + let Json(updated) = scoped_ticket_case_update_with_context( State(self.workspace_api()?.clone()), contract_request_headers(&context)?, - AxumPath((workspace_id, id)), - Json(request), + &context, + AxumPath((workspace_id, ticket_id)), + Json(ticket::TicketCaseUpdate { + expected_content_digest: request.expected_content_digest, + expected_state: request.expected_state, + expected_closure_operation_key: request.expected_closure_operation_key, + operation_key: request.operation_key, + action: request.action, + explanation: request.explanation, + references: request.references, + author: None, + }), ) .await .map_err(ApiError::into_repository_api_error)?; - Ok(server_api::TicketRecord(response)) + let meta = updated.meta; + Ok(server_api::TicketSummary(ticket::TicketSummary { + id: meta.id, + resource_key: meta.resource_key, + slug: meta.slug, + title: meta.title, + status: meta.status, + kind: meta.kind, + priority: meta.priority, + labels: meta.labels, + tags: updated.tags, + readiness: meta.readiness, + workflow_state: meta.workflow_state, + workflow_state_explicit: meta.workflow_state_explicit, + queued_by: meta.queued_by, + queued_at: meta.queued_at, + updated_at: meta.updated_at, + })) } - async fn ticket_state_update( + async fn workspace_ticket_tag_list( &self, context: server_api::ServerRequestContext, workspace_id: String, - id: String, - request: server_api::TicketStateUpdateRequest, - ) -> std::result::Result { - let Json(response) = scoped_ticket_state_update( - State(self.workspace_api()?.clone()), + request: server_api::WorkspaceTicketTagListRequest, + ) -> std::result::Result + { + let result = execute_ticket_rest_operation_with_context( + self.workspace_api()?, + &workspace_id, contract_request_headers(&context)?, - AxumPath((workspace_id, id)), - Json(request.0), + Some(&context), + TicketBackendOperation::ListWorkspaceTags { request: request.0 }, ) .await .map_err(ApiError::into_repository_api_error)?; - Ok(server_api::TicketRecord(response)) + let Json(value) = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::WorkspaceTags(value) => Some(value), + _ => None, + }) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::WorkspaceTicketTagList(value)) } - async fn ticket_close_record( + async fn workspace_ticket_tag_mutate( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkspaceTicketTagMutationRequest, + ) -> std::result::Result { + let result = execute_ticket_rest_operation_with_context( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + Some(&context), + TicketBackendOperation::MutateWorkspaceTag { request: request.0 }, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + let Json(value) = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::WorkspaceTag(value) => Some(value), + _ => None, + }) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::WorkspaceTicketTag(value)) + } + + async fn ticket_tags_set( &self, context: server_api::ServerRequestContext, workspace_id: String, id: String, - request: server_api::TicketCloseRecordRequest, - ) -> std::result::Result<(), server_api::RepositoryApiError> { - scoped_close_ticket_record( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id)), + request: server_api::TicketTagsSetRequest, + ) -> std::result::Result + { + let result = execute_ticket_rest_operation_with_context( + self.workspace_api()?, + &workspace_id, contract_request_headers(&context)?, - Json(request.0), + Some(&context), + TicketBackendOperation::SetTicketTags { + id: TicketIdOrSlug::Query(id), + request: request.0, + }, ) .await .map_err(ApiError::into_repository_api_error)?; - Ok(()) + let Json(value) = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::WorkspaceTags(value) => Some(value), + _ => None, + }) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::WorkspaceTicketTagList(value)) } async fn ticket_relation_view( @@ -10560,6 +10966,233 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } + async fn ticket_assignments_page( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentsQuery, + ) -> std::result::Result + { + let api = self.workspace_api()?.clone(); + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &workspace_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + api.store + .page_ticket_assignments( + &workspace_id, + &ticket_id, + query.after_assignment_id.as_deref(), + query.limit.unwrap_or(30), + ) + .map(server_api::TicketAssignmentsPage) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error) + }) + .await + } + async fn ticket_assignments_page_browser( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentsQuery, + ) -> std::result::Result + { + self.ticket_assignments_page(context, workspace_id, ticket_id, query) + .await + } + async fn ticket_assignment_diagnostics( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentDiagnosticsQuery, + ) -> std::result::Result< + server_api::TicketAssignmentDiagnosticsPage, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?.clone(); + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &workspace_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + api.store + .page_ticket_assignment_diagnostics( + &workspace_id, + &ticket_id, + &query.assignment_id, + query.after_input_event_id.as_deref(), + query.after_start_operation_id.as_deref(), + query.limit.unwrap_or(30), + ) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error) + }) + .await + } + async fn ticket_assignment_diagnostics_browser( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentDiagnosticsQuery, + ) -> std::result::Result< + server_api::TicketAssignmentDiagnosticsPage, + server_api::RepositoryApiError, + > { + self.ticket_assignment_diagnostics(context, workspace_id, ticket_id, query) + .await + } + async fn ticket_assignment_answer( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + request: server_api::TicketAssignmentAnswerRequest, + ) -> std::result::Result< + server_api::TicketAssignmentAnswerResponse, + server_api::RepositoryApiError, + > { + let api = self.workspace_api()?.clone(); + // Mixed live Runtime discovery, whole answer transaction, and committed-input + // dispatch share one accepted finite Host owner, including waiter cancellation. + // Neither Runtime I/O nor its completion waits on the sole DB worker. + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &workspace_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let (actor, source) = authenticated_assignment_source( + &api, + &contract_request_headers(&context)?, + &context, + ) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let receipt = api + .store + .answer_ticket_assignment( + &workspace_id, + &ticket_id, + &request.0, + &source, + &actor, + ) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + // The entire event/audit/receipt transaction has committed. Response loss + // retries the saved receipt; delivery scan deduplicates accepted inputs. + if let Some(orchestrator) = find_workspace_orchestrator(&api) { + dispatch_orchestrator_assignment_attention(&api, &orchestrator.worker); + } + Ok(server_api::TicketAssignmentAnswerResponse(receipt)) + }) + .await + } + async fn ticket_assignment_answer_browser( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + request: server_api::TicketAssignmentAnswerRequest, + ) -> std::result::Result< + server_api::TicketAssignmentAnswerResponse, + server_api::RepositoryApiError, + > { + self.ticket_assignment_answer(context, workspace_id, ticket_id, request) + .await + } + + async fn ticket_assignment_tracking_get( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentTrackingQuery, + ) -> std::result::Result + { + let api = self.workspace_api()?.clone(); + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &workspace_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id) + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + let values = if let Some(assignment_id) = query.assignment_id { + api.store + .get_ticket_assignment_tracking(&workspace_id, &ticket_id, &assignment_id) + .map(|value| value.into_iter().collect::>()) + } else { + api.authority.ticket_assignment_tracking(&ticket_id) + } + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketAssignmentTrackingList(values)) + }) + .await + } + + async fn ticket_assignment_tracking_update( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + request: server_api::TicketAssignmentTrackingUpdateRequest, + ) -> std::result::Result< + server_api::TicketAssignmentTrackingRecord, + server_api::RepositoryApiError, + > { + let Json(value) = scoped_ticket_assignment_tracking_update_with_context( + State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + &context, + AxumPath((workspace_id, ticket_id)), + Json(request.0), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketAssignmentTrackingRecord(value)) + } + + async fn ticket_assignment_tracking_history( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentTrackingHistoryQuery, + ) -> std::result::Result< + server_api::TicketAssignmentTrackingHistoryPage, + server_api::RepositoryApiError, + > { + let Json(value) = scoped_ticket_assignment_tracking_history( + State(self.workspace_api()?.clone()), + AxumPath((workspace_id, ticket_id)), + Query(query), + ) + .await + .map_err(ApiError::into_repository_api_error)?; + Ok(value) + } + async fn ticket_assignment_list( &self, workspace_id: String, @@ -10579,6 +11212,7 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_assignment_set( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, role: String, @@ -10587,8 +11221,10 @@ impl server_api::ServerApi for ServerApiContractService { server_api::TicketRoleAssignmentMutationResponse, server_api::RepositoryApiError, > { - let Json(response) = scoped_set_ticket_assignment( + let Json(response) = scoped_set_ticket_assignment_with_context( State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + &context, AxumPath((workspace_id, id, role)), Json(request), ) @@ -10599,6 +11235,7 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_assignment_clear( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, role: String, @@ -10607,8 +11244,10 @@ impl server_api::ServerApi for ServerApiContractService { server_api::TicketRoleAssignmentMutationResponse, server_api::RepositoryApiError, > { - let Json(response) = scoped_clear_ticket_assignment( + let Json(response) = scoped_clear_ticket_assignment_with_context( State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, + &context, AxumPath((workspace_id, id, role)), Query(query), ) @@ -10633,24 +11272,6 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } - async fn ticket_state_transition( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::BrowserTransitionTicketStateRequest, - ) -> std::result::Result { - let Json(response) = scoped_transition_ticket_state( - State(self.workspace_api()?.clone()), - contract_request_headers(&context)?, - AxumPath(ScopedRecordPath { workspace_id, id }), - Json(request), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(response) - } - async fn ticket_ready( &self, context: server_api::ServerRequestContext, @@ -10708,24 +11329,6 @@ impl server_api::ServerApi for ServerApiContractService { Ok(server_api::TicketQueueResponse(response)) } - async fn ticket_close( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::BrowserCloseTicketRequest, - ) -> std::result::Result { - let Json(response) = scoped_close_ticket( - State(self.workspace_api()?.clone()), - contract_request_headers(&context)?, - AxumPath(ScopedRecordPath { workspace_id, id }), - Json(request), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(response) - } - async fn objective_list_alias( &self, query: server_api::ObjectiveListQuery, @@ -11270,7 +11873,7 @@ impl server_api::ServerApi for ServerApiContractService { let api = self.workspace_api().map_err(|_| { workspace_config::pre_error(Error::Store("Workspace unavailable".into())) })?; - let worker = workspace_config::identity(api, &context, &workspace_id)?; + let worker = workspace_config::identity(api, &context, &workspace_id).await?; workspace_config::get(api, &worker) .await .map(server_api::WorkspaceConfigCurrentResponse) @@ -11287,7 +11890,7 @@ impl server_api::ServerApi for ServerApiContractService { let api = self.workspace_api().map_err(|_| { workspace_config::pre_error(Error::Store("Workspace unavailable".into())) })?; - let worker = workspace_config::identity(api, &context, &workspace_id)?; + let worker = workspace_config::identity(api, &context, &workspace_id).await?; workspace_config::attach(api, &worker, request).await } async fn current_worker_workspace_config_observe( @@ -11302,7 +11905,7 @@ impl server_api::ServerApi for ServerApiContractService { let api = self.workspace_api().map_err(|_| { workspace_config::pre_error(Error::Store("Workspace unavailable".into())) })?; - let worker = workspace_config::identity(api, &context, &workspace_id)?; + let worker = workspace_config::identity(api, &context, &workspace_id).await?; workspace_config::observe(api, &worker, request).await } async fn current_worker_workspace_config_read( @@ -11317,7 +11920,7 @@ impl server_api::ServerApi for ServerApiContractService { let api = self.workspace_api().map_err(|_| { workspace_config::pre_error(Error::Store("Workspace unavailable".into())) })?; - let worker = workspace_config::identity(api, &context, &workspace_id)?; + let worker = workspace_config::identity(api, &context, &workspace_id).await?; workspace_config::read(api, &worker, request).await } async fn current_worker_workspace_config_commit( @@ -11332,7 +11935,7 @@ impl server_api::ServerApi for ServerApiContractService { let api = self.workspace_api().map_err(|_| { workspace_config::pre_error(Error::Store("Workspace unavailable".into())) })?; - let worker = workspace_config::identity(api, &context, &workspace_id)?; + let worker = workspace_config::identity(api, &context, &workspace_id).await?; workspace_config::commit(api, &worker, request).await } async fn workspace_config_grant_create( @@ -11372,21 +11975,30 @@ impl server_api::ServerApi for ServerApiContractService { server_api::CurrentWorkerWorkdirCatalogResponse, server_api::RepositoryApiError, > { - let api = self.workspace_api()?; - validate_workspace_scope(api, &workspace_id) + let api = self.workspace_api()?.clone(); + let headers = current_worker_contract_headers(&context)?; + let stage_api = api.clone(); + let worker = api + .host_executors + .host() + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)? + .run(move || -> ApiResult<_> { + validate_workspace_scope(&stage_api, &workspace_id)?; + Ok(current_worker_identity( + &stage_api, + &workspace_id, + &headers, + )?) + }) + .await .map_err(ApiError::into_repository_api_error)?; - let worker = current_worker_identity( - api, - &workspace_id, - ¤t_worker_contract_headers(&context)?, - ) - .map_err(ApiError::from) - .map_err(ApiError::into_repository_api_error)?; - { - let session_lock = current_worker_session_lock(api, &worker); - let _guard = session_lock.lock().await; - list_current_worker_workdir_catalog_for_worker(api, &worker, query) - } + let session_lock = current_worker_session_lock(&api, &worker); + let guard = Arc::new(session_lock.lock_owned().await); + run_worker_workdir_db_stage_locked(&api, &guard, move |api| { + list_current_worker_workdir_catalog_for_worker(&api, &worker, query) + }) + .await .map_err(ApiError::into_repository_api_error) } @@ -11399,20 +12011,31 @@ impl server_api::ServerApi for ServerApiContractService { server_api::CurrentWorkerWorkdirAttachmentListResponse, server_api::RepositoryApiError, > { - let api = self.workspace_api()?; - validate_workspace_scope(api, &workspace_id) + let api = self.workspace_api()?.clone(); + let headers = current_worker_contract_headers(&context)?; + let stage_api = api.clone(); + let worker = api + .host_executors + .host() + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)? + .run(move || -> ApiResult<_> { + validate_workspace_scope(&stage_api, &workspace_id)?; + Ok(current_worker_identity( + &stage_api, + &workspace_id, + &headers, + )?) + }) + .await .map_err(ApiError::into_repository_api_error)?; - let worker = current_worker_identity( - api, - &workspace_id, - ¤t_worker_contract_headers(&context)?, - ) - .map_err(ApiError::from) - .map_err(ApiError::into_repository_api_error)?; - let session_lock = current_worker_session_lock(api, &worker); - let _session_guard = session_lock.lock().await; - list_current_worker_workdir_attachments(api, &worker, query) - .map_err(ApiError::into_repository_api_error) + let session_lock = current_worker_session_lock(&api, &worker); + let guard = Arc::new(session_lock.lock_owned().await); + run_worker_workdir_db_stage_locked(&api, &guard, move |api| { + list_current_worker_workdir_attachments(&api, &worker, query) + }) + .await + .map_err(ApiError::into_repository_api_error) } async fn current_worker_workdir_attach( @@ -11625,6 +12248,16 @@ impl server_api::ServerApi for ServerApiContractService { } } +fn runtime_execution_error(error: Error) -> runtime_api::RuntimeApiError { + let code = match &error { + Error::DbCapacityExceeded => "db_capacity_exceeded", + Error::DbExecutorUnavailable => "db_executor_unavailable", + Error::DbOperationOutcomeUnknown => "db_operation_outcome_unknown", + _ => "backend_execution_failed", + }; + runtime_api::RuntimeApiError::new(api_error_status(&error).as_u16(), code, error.to_string()) +} + async fn scoped_worker_session_attachment( State(api): State, AxumPath((workspace_id, runtime_id, worker_id, session_id, attachment_id)): AxumPath<( @@ -11644,10 +12277,25 @@ async fn scoped_worker_session_attachment( .into_response(); } let worker = RuntimeWorkerRef::new(runtime_id, worker_id); - match api - .runtime - .worker_session_attachment(&workspace_id, &worker, &session_id, &attachment_id) - { + let executor = match api.host_executors.host() { + Ok(value) => value, + Err(error) => return ApiError::from(error).into_response(), + }; + let fetched = executor + .run(move || { + Ok::<_, Error>(api.runtime.worker_session_attachment( + &workspace_id, + &worker, + &session_id, + &attachment_id, + )) + }) + .await; + let fetched = match fetched { + Ok(value) => value, + Err(error) => return ApiError::from(error).into_response(), + }; + match fetched { Ok(attachment) => { let Ok(content_type) = HeaderValue::from_str(&attachment.media_type) else { return ( @@ -11888,15 +12536,28 @@ pub async fn serve( let orchestrator_hook = tokio::spawn(run_orchestrator_turn_end_hook(api.clone())); let result = axum::serve(listener, build_router(api.clone())).await; orchestrator_hook.abort(); - let storage_result = api - .feature_storage - .shutdown() - .map_err(|error| Error::Store(format!("failed to shut down Feature storage: {error}"))); + let _ = orchestrator_hook.await; + let storage_result = shutdown_workspace_feature_storage(&api).await; result?; storage_result?; Ok(()) } +// Feature shutdown fences new operations and waits for synchronous connection +// holders. Accepted close retains ownership after its async waiter disappears. +async fn shutdown_workspace_feature_storage(api: &WorkspaceApi) -> Result<()> { + api.worker_projection.shutdown_async().await; + let storage = api.feature_storage.clone(); + api.store + .db_executor() + .run(move || { + storage.shutdown().map_err(|error| { + Error::Store(format!("failed to shut down Feature storage: {error}")) + }) + }) + .await +} + #[derive(Debug, Serialize, Deserialize)] pub struct ProjectListResponse { pub workspace_id: String, @@ -12114,8 +12775,13 @@ async fn scoped_list_flows( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult>> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(api.store.list_flow_sources(&path.workspace_id)?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.store.list_flow_sources(&path.workspace_id)?)) + }) + .await } async fn scoped_put_flow( @@ -12123,28 +12789,34 @@ async fn scoped_put_flow( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let definition = flow::compile_flow_source(&request.content).map_err(|error| { - Error::InvalidInput(format!( - "invalid Flow source: {}", - serde_json::to_string(&error.diagnostics) - .unwrap_or_else(|_| "diagnostics unavailable".to_string()) - )) - })?; - let expected_path = format!("flows/{}.dcdl", definition.name); - if request.path != expected_path { - return Err( - Error::InvalidInput(format!("Flow source path must be `{expected_path}`")).into(), - ); - } - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); - Ok(Json(api.store.put_flow_source_for_kind( - &path.workspace_id, - FlowSourceKind::Workspace, - &request.path, - &request.content, - &now, - )?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let definition = flow::compile_flow_source(&request.content).map_err(|error| { + Error::InvalidInput(format!( + "invalid Flow source: {}", + serde_json::to_string(&error.diagnostics) + .unwrap_or_else(|_| "diagnostics unavailable".to_string()) + )) + })?; + let expected_path = format!("flows/{}.dcdl", definition.name); + if request.path != expected_path { + return Err(Error::InvalidInput(format!( + "Flow source path must be `{expected_path}`" + )) + .into()); + } + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); + Ok(Json(api.store.put_flow_source_for_kind( + &path.workspace_id, + FlowSourceKind::Workspace, + &request.path, + &request.content, + &now, + )?)) + }) + .await } async fn scoped_resolve_flow_source( @@ -12152,65 +12824,79 @@ async fn scoped_resolve_flow_source( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let resolved = match &request.selector { - flow::FlowSelector::Builtin { slug } => { - let builtin = flow::builtin_flow_source(slug) - .ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?; - let definition = builtin.compile().map_err(|error| { - Error::Store(format!( - "compile built-in Flow {slug:?}: {:?}", - error.diagnostics - )) - })?; - ResolvedFlowSource { - selector: request.selector.clone(), - workspace_id: path.workspace_id, - flow_id: format!("builtin:{slug}"), - content_digest: definition.content_digest.clone(), - definition, - } - } - flow::FlowSelector::Workspace { slug } => { - let source = api - .store - .get_flow_source_by_name(&path.workspace_id, FlowSourceKind::Workspace, slug)? - .ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?; - let content = api - .store - .get_flow_source_content( - &path.workspace_id, - &source.flow_id, - &source.content_digest, - )? - .ok_or_else(|| { - Error::Store(format!( - "resolved Flow content {}@{} is missing", - source.flow_id, source.content_digest - )) - })?; - ResolvedFlowSource { - selector: request.selector.clone(), - workspace_id: path.workspace_id, - flow_id: source.flow_id, - content_digest: content.content_digest, - definition: content.definition, - } - } - }; - Ok(Json(resolved)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let resolved = match &request.selector { + flow::FlowSelector::Builtin { slug } => { + let builtin = flow::builtin_flow_source(slug) + .ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?; + let definition = builtin.compile().map_err(|error| { + Error::Store(format!( + "compile built-in Flow {slug:?}: {:?}", + error.diagnostics + )) + })?; + ResolvedFlowSource { + selector: request.selector.clone(), + workspace_id: path.workspace_id, + flow_id: format!("builtin:{slug}"), + content_digest: definition.content_digest.clone(), + definition, + } + } + flow::FlowSelector::Workspace { slug } => { + let source = api + .store + .get_flow_source_by_name( + &path.workspace_id, + FlowSourceKind::Workspace, + slug, + )? + .ok_or_else(|| Error::InvalidRecordId(request.selector.to_string()))?; + let content = api + .store + .get_flow_source_content( + &path.workspace_id, + &source.flow_id, + &source.content_digest, + )? + .ok_or_else(|| { + Error::Store(format!( + "resolved Flow content {}@{} is missing", + source.flow_id, source.content_digest + )) + })?; + ResolvedFlowSource { + selector: request.selector.clone(), + workspace_id: path.workspace_id, + flow_id: source.flow_id, + content_digest: content.content_digest, + definition: content.definition, + } + } + }; + Ok(Json(resolved)) + }) + .await } async fn scoped_get_flow( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let source = api - .store - .get_flow_source(&path.workspace_id, &path.flow_id)? - .ok_or_else(|| Error::InvalidRecordId(path.flow_id))?; - Ok(Json(source)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let source = api + .store + .get_flow_source(&path.workspace_id, &path.flow_id)? + .ok_or_else(|| Error::InvalidRecordId(path.flow_id))?; + Ok(Json(source)) + }) + .await } async fn scoped_get_workspace_settings( @@ -12281,8 +12967,13 @@ async fn scoped_get_workspace_signing_identity( "Workspace public identity access", ) .await?; - let identity = api.signing_identities.get_validated(&path.workspace_id)?; - Ok(Json(project_workspace_signing_identity(&api, identity)?)) + api.store + .db_executor() + .run(move || { + let identity = api.signing_identities.get_validated(&path.workspace_id)?; + Ok(Json(project_workspace_signing_identity(&api, identity)?)) + }) + .await } async fn scoped_provision_workspace_signing_identity( @@ -12297,10 +12988,15 @@ async fn scoped_provision_workspace_signing_identity( "Workspace signing identity provisioning", ) .await?; - let identity = api - .signing_identities - .provision_existing(&path.workspace_id, &actor.account_id)?; - Ok(Json(project_workspace_signing_identity(&api, identity)?)) + api.store + .db_executor() + .run(move || { + let identity = api + .signing_identities + .provision_existing(&path.workspace_id, &actor.account_id)?; + Ok(Json(project_workspace_signing_identity(&api, identity)?)) + }) + .await } fn project_workspace_signing_identity( @@ -12378,12 +13074,19 @@ async fn scoped_get_workspace_config_history( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let snapshot = api - .config_store - .load_workspace_config_history(&path.workspace_id, &path.content_digest)? - .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.content_digest.clone())))?; - Ok(Json(config_tree_snapshot_to_api(snapshot))) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let snapshot = api + .config_store + .load_workspace_config_history(&path.workspace_id, &path.content_digest)? + .ok_or_else(|| { + ApiError::from(Error::InvalidRecordId(path.content_digest.clone())) + })?; + Ok(Json(config_tree_snapshot_to_api(snapshot))) + }) + .await } #[derive(Debug, Deserialize)] @@ -12396,34 +13099,44 @@ async fn scoped_get_prompt_projection( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = api - .config_store - .load_workspace_config(&path.workspace_id)? - .ok_or_else(|| { - ApiError::from(Error::InvalidInput( - "Workspace config is not initialized".to_string(), - )) - })?; - let projection = api - .prompt_projection_cache - .resolve(&path.workspace_id, &state)?; - Ok(Json(projection.as_ref().clone())) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + ApiError::from(Error::InvalidInput( + "Workspace config is not initialized".to_string(), + )) + })?; + let projection = api + .prompt_projection_cache + .resolve(&path.workspace_id, &state)?; + Ok(Json(projection.as_ref().clone())) + }) + .await } async fn scoped_get_workspace_memory_settings( State(api): State, AxumPath(workspace_id): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - let settings = api - .config_store - .get_workspace_memory_settings(&workspace_id) - .map_err(ApiError::from)?; - Ok(Json(server_api::WorkspaceMemorySettings { - workspace_id: settings.workspace_id, - language: settings.language, - })) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let settings = api + .config_store + .get_workspace_memory_settings(&workspace_id) + .map_err(ApiError::from)?; + Ok(Json(server_api::WorkspaceMemorySettings { + workspace_id: settings.workspace_id, + language: settings.language, + })) + }) + .await } async fn scoped_update_workspace_memory_settings( @@ -12431,19 +13144,24 @@ async fn scoped_update_workspace_memory_settings( AxumPath(workspace_id): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - let settings = api - .config_store - .update_workspace_memory_settings( - &workspace_id, - &request.expected_language, - &request.language, - ) - .map_err(ApiError::from)?; - Ok(Json(server_api::WorkspaceMemorySettings { - workspace_id: settings.workspace_id, - language: settings.language, - })) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let settings = api + .config_store + .update_workspace_memory_settings( + &workspace_id, + &request.expected_language, + &request.language, + ) + .map_err(ApiError::from)?; + Ok(Json(server_api::WorkspaceMemorySettings { + workspace_id: settings.workspace_id, + language: settings.language, + })) + }) + .await } async fn require_workspace_owner( @@ -12497,10 +13215,15 @@ async fn scoped_get_repository_access_projection( Extension(actor): Extension, ) -> ApiResult> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - Ok(Json(active_repository_access_projection( - &api, - &path.workspace_id, - )?)) + api.store + .db_executor() + .run(move || { + Ok(Json(active_repository_access_projection( + &api, + &path.workspace_id, + )?)) + }) + .await } async fn scoped_list_repository_ssh_credentials( @@ -12509,13 +13232,18 @@ async fn scoped_list_repository_ssh_credentials( Extension(actor): Extension, ) -> ApiResult>> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - api.repository_secrets - .ensure_workspace_default_credential(&path.workspace_id)?; - let projection = active_repository_access_projection(&api, &path.workspace_id)?; - Ok(Json( - api.repository_secrets - .list_credentials(&path.workspace_id, &projection)?, - )) + api.store + .db_executor() + .run(move || { + api.repository_secrets + .ensure_workspace_default_credential(&path.workspace_id)?; + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + Ok(Json( + api.repository_secrets + .list_credentials(&path.workspace_id, &projection)?, + )) + }) + .await } async fn scoped_get_repository_ssh_credential( @@ -12524,14 +13252,19 @@ async fn scoped_get_repository_ssh_credential( Extension(actor): Extension, ) -> ApiResult> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - let projection = active_repository_access_projection(&api, &path.workspace_id)?; - let credential = api - .repository_secrets - .list_credentials(&path.workspace_id, &projection)? - .into_iter() - .find(|credential| credential.credential_id == path.credential_id) - .ok_or_else(|| Error::InvalidRecordId(path.credential_id.clone()))?; - Ok(Json(credential)) + api.store + .db_executor() + .run(move || { + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + let credential = api + .repository_secrets + .list_credentials(&path.workspace_id, &projection)? + .into_iter() + .find(|credential| credential.credential_id == path.credential_id) + .ok_or_else(|| Error::InvalidRecordId(path.credential_id.clone()))?; + Ok(Json(credential)) + }) + .await } async fn scoped_get_repository_ssh_public_key( @@ -12540,11 +13273,16 @@ async fn scoped_get_repository_ssh_public_key( Extension(actor): Extension, ) -> ApiResult> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - let public_key = api - .repository_secrets - .credential_public_key(&path.workspace_id, &path.credential_id)? - .ok_or_else(|| Error::InvalidRecordId(path.credential_id.clone()))?; - Ok(Json(public_key)) + api.store + .db_executor() + .run(move || { + let public_key = api + .repository_secrets + .credential_public_key(&path.workspace_id, &path.credential_id)? + .ok_or_else(|| Error::InvalidRecordId(path.credential_id.clone()))?; + Ok(Json(public_key)) + }) + .await } async fn scoped_create_repository_ssh_credential( @@ -12554,18 +13292,25 @@ async fn scoped_create_repository_ssh_credential( Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - if request.credential_id - == crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID - { - return Err(Error::InvalidInput( - "Workspace default SSH credential id is reserved".to_string(), - ) - .into()); - } - let credential = - api.repository_secrets - .create_credential(&path.workspace_id, request, &actor.account_id)?; - Ok((StatusCode::CREATED, Json(credential))) + api.store + .db_executor() + .run(move || { + if request.credential_id + == crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID + { + return Err(Error::InvalidInput( + "Workspace default SSH credential id is reserved".to_string(), + ) + .into()); + } + let credential = api.repository_secrets.create_credential( + &path.workspace_id, + request, + &actor.account_id, + )?; + Ok((StatusCode::CREATED, Json(credential))) + }) + .await } async fn scoped_generate_repository_ssh_credential( @@ -12575,20 +13320,25 @@ async fn scoped_generate_repository_ssh_credential( Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - if request.credential_id - == crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID - { - return Err(Error::InvalidInput( - "Workspace default SSH credential id is reserved".to_string(), - ) - .into()); - } - let credential = api.repository_secrets.generate_credential( - &path.workspace_id, - request, - &actor.account_id, - )?; - Ok((StatusCode::CREATED, Json(credential))) + api.store + .db_executor() + .run(move || { + if request.credential_id + == crate::repository_access::WORKSPACE_DEFAULT_REPOSITORY_SSH_CREDENTIAL_ID + { + return Err(Error::InvalidInput( + "Workspace default SSH credential id is reserved".to_string(), + ) + .into()); + } + let credential = api.repository_secrets.generate_credential( + &path.workspace_id, + request, + &actor.account_id, + )?; + Ok((StatusCode::CREATED, Json(credential))) + }) + .await } async fn scoped_rotate_repository_ssh_credential( @@ -12598,12 +13348,17 @@ async fn scoped_rotate_repository_ssh_credential( Json(request): Json, ) -> ApiResult> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - Ok(Json(api.repository_secrets.rotate_credential( - &path.workspace_id, - &path.credential_id, - request, - &actor.account_id, - )?)) + api.store + .db_executor() + .run(move || { + Ok(Json(api.repository_secrets.rotate_credential( + &path.workspace_id, + &path.credential_id, + request, + &actor.account_id, + )?)) + }) + .await } async fn scoped_delete_repository_ssh_credential( @@ -12613,15 +13368,20 @@ async fn scoped_delete_repository_ssh_credential( Json(request): Json, ) -> ApiResult { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - let projection = active_repository_access_projection(&api, &path.workspace_id)?; - api.repository_secrets.delete_credential( - &path.workspace_id, - &path.credential_id, - request, - &actor.account_id, - &projection, - )?; - Ok(StatusCode::NO_CONTENT) + api.store + .db_executor() + .run(move || { + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + api.repository_secrets.delete_credential( + &path.workspace_id, + &path.credential_id, + request, + &actor.account_id, + &projection, + )?; + Ok(StatusCode::NO_CONTENT) + }) + .await } async fn scoped_list_repository_ssh_host_trusts( @@ -12630,11 +13390,16 @@ async fn scoped_list_repository_ssh_host_trusts( Extension(actor): Extension, ) -> ApiResult>> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - let projection = active_repository_access_projection(&api, &path.workspace_id)?; - Ok(Json( - api.repository_secrets - .list_host_trusts(&path.workspace_id, &projection)?, - )) + api.store + .db_executor() + .run(move || { + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + Ok(Json( + api.repository_secrets + .list_host_trusts(&path.workspace_id, &projection)?, + )) + }) + .await } async fn scoped_get_repository_ssh_host_trust( @@ -12643,14 +13408,19 @@ async fn scoped_get_repository_ssh_host_trust( Extension(actor): Extension, ) -> ApiResult> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - let projection = active_repository_access_projection(&api, &path.workspace_id)?; - let host_trust = api - .repository_secrets - .list_host_trusts(&path.workspace_id, &projection)? - .into_iter() - .find(|host_trust| host_trust.host_trust_id == path.host_trust_id) - .ok_or_else(|| Error::InvalidRecordId(path.host_trust_id.clone()))?; - Ok(Json(host_trust)) + api.store + .db_executor() + .run(move || { + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + let host_trust = api + .repository_secrets + .list_host_trusts(&path.workspace_id, &projection)? + .into_iter() + .find(|host_trust| host_trust.host_trust_id == path.host_trust_id) + .ok_or_else(|| Error::InvalidRecordId(path.host_trust_id.clone()))?; + Ok(Json(host_trust)) + }) + .await } async fn scoped_put_repository_ssh_host_trust( @@ -12660,15 +13430,22 @@ async fn scoped_put_repository_ssh_host_trust( Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - let status = if request.expected_fingerprint.is_some() { - StatusCode::OK - } else { - StatusCode::CREATED - }; - let host_trust = - api.repository_secrets - .put_host_trust(&path.workspace_id, request, &actor.account_id)?; - Ok((status, Json(host_trust))) + api.store + .db_executor() + .run(move || { + let status = if request.expected_fingerprint.is_some() { + StatusCode::OK + } else { + StatusCode::CREATED + }; + let host_trust = api.repository_secrets.put_host_trust( + &path.workspace_id, + request, + &actor.account_id, + )?; + Ok((status, Json(host_trust))) + }) + .await } async fn scoped_delete_repository_ssh_host_trust( @@ -12678,15 +13455,20 @@ async fn scoped_delete_repository_ssh_host_trust( Json(request): Json, ) -> ApiResult { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; - let projection = active_repository_access_projection(&api, &path.workspace_id)?; - api.repository_secrets.delete_host_trust( - &path.workspace_id, - &path.host_trust_id, - request, - &actor.account_id, - &projection, - )?; - Ok(StatusCode::NO_CONTENT) + api.store + .db_executor() + .run(move || { + let projection = active_repository_access_projection(&api, &path.workspace_id)?; + api.repository_secrets.delete_host_trust( + &path.workspace_id, + &path.host_trust_id, + request, + &actor.account_id, + &projection, + )?; + Ok(StatusCode::NO_CONTENT) + }) + .await } fn config_content_type_to_api( @@ -12954,41 +13736,51 @@ async fn scoped_get_workspace_config_tree( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = api - .config_store - .load_workspace_config(&path.workspace_id)? - .unwrap_or_else(|| crate::config_source::WorkspaceConfigState { - snapshot: ConfigTreeSnapshot::empty(), - contract: config_source::ToolchainContract::new( - config_source::DEFAULT_SCHEMA_VERSION, - Vec::new(), - config_source::DEFAULT_IMPORT_POLICY_VERSION, - ), - projection_digest: config_source::digest_bytes(b"[]"), - }); - Ok(Json(workspace_config_state_to_api(state))) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .unwrap_or_else(|| crate::config_source::WorkspaceConfigState { + snapshot: ConfigTreeSnapshot::empty(), + contract: config_source::ToolchainContract::new( + config_source::DEFAULT_SCHEMA_VERSION, + Vec::new(), + config_source::DEFAULT_IMPORT_POLICY_VERSION, + ), + projection_digest: config_source::digest_bytes(b"[]"), + }); + Ok(Json(workspace_config_state_to_api(state))) + }) + .await } async fn scoped_get_workspace_config_entry( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let virtual_path = config_source::VirtualPath::parse(&path.path) - .map_err(|error| ApiError::from(Error::InvalidInput(error.to_string())))?; - let state = api - .config_store - .load_workspace_config(&path.workspace_id)? - .ok_or_else(|| { - ApiError::from(Error::InvalidRecordId("virtual config source tree".into())) - })?; - let entry = state - .snapshot - .get(&virtual_path) - .cloned() - .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.path)))?; - Ok(Json(config_entry_to_api(entry))) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let virtual_path = config_source::VirtualPath::parse(&path.path) + .map_err(|error| ApiError::from(Error::InvalidInput(error.to_string())))?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + ApiError::from(Error::InvalidRecordId("virtual config source tree".into())) + })?; + let entry = state + .snapshot + .get(&virtual_path) + .cloned() + .ok_or_else(|| ApiError::from(Error::InvalidRecordId(path.path)))?; + Ok(Json(config_entry_to_api(entry))) + }) + .await } async fn scoped_commit_workspace_config_tree( @@ -12996,13 +13788,18 @@ async fn scoped_commit_workspace_config_tree( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { - let request = config_commit_request_from_api(request)?; - validate_workspace_scope(&api, &path.workspace_id)?; - let state = commit_workspace_config_tree(&api, &path.workspace_id, &request)?; - Ok(( - StatusCode::CREATED, - Json(workspace_config_state_to_api(state)), - )) + api.host_executors + .host()? + .run(move || { + let request = config_commit_request_from_api(request)?; + validate_workspace_scope(&api, &path.workspace_id)?; + let state = commit_workspace_config_tree(&api, &path.workspace_id, &request)?; + Ok(( + StatusCode::CREATED, + Json(workspace_config_state_to_api(state)), + )) + }) + .await } /// Shared canonical commit and activation boundary for UI and logical config operations. @@ -13066,20 +13863,25 @@ async fn scoped_get_profile_settings( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = api - .config_store - .load_workspace_config(&path.workspace_id)? - .ok_or_else(|| { - ApiError::from(Error::InvalidRecordId("virtual config source tree".into())) - })?; - Ok(Json( - crate::profile_settings::project_profiles_from_workspace_config( - &path.workspace_id, - &state, - )? - .settings, - )) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + ApiError::from(Error::InvalidRecordId("virtual config source tree".into())) + })?; + Ok(Json( + crate::profile_settings::project_profiles_from_workspace_config( + &path.workspace_id, + &state, + )? + .settings, + )) + }) + .await } async fn scoped_list_tickets( @@ -13095,8 +13897,13 @@ async fn scoped_get_ticket( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - browser_ticket_detail(&api, &path.id) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + browser_ticket_detail(&api, &path.id) + }) + .await } async fn scoped_query_tickets( @@ -13104,8 +13911,13 @@ async fn scoped_query_tickets( AxumPath(path): AxumPath, Json(query): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(api.authority.query_tickets(query)?)) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.query_tickets(query)?)) + }) + .await } async fn scoped_show_ticket( @@ -13113,11 +13925,16 @@ async fn scoped_show_ticket( AxumPath(path): AxumPath, Json(query): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let backend = browser_ticket_backend(&api)?; - Ok(Json(api.authority.read_ticket_detail_with_backend( - &path.id, query, &backend, - )?)) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let backend = browser_ticket_backend(&api)?; + Ok(Json(api.authority.read_ticket_detail_with_backend( + &path.id, query, &backend, + )?)) + }) + .await } fn parse_ticket_assignment_role(role: &str) -> ApiResult { @@ -13193,19 +14010,24 @@ async fn scoped_list_ticket_assignments( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let ticket = api.authority.ticket(&path.id)?; - let assignments = api - .store - .list_current_ticket_role_assignments(&path.workspace_id, &ticket.id)?; - Ok(Json(server_api::TicketRoleAssignmentsResponse { - workspace_id: path.workspace_id, - ticket_id: ticket.id, - assignments: assignments - .into_iter() - .map(project_ticket_role_assignment_record) - .collect(), - })) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let ticket = api.authority.ticket(&path.id)?; + let assignments = api + .store + .list_current_ticket_role_assignments(&path.workspace_id, &ticket.id)?; + Ok(Json(server_api::TicketRoleAssignmentsResponse { + workspace_id: path.workspace_id, + ticket_id: ticket.id, + assignments: assignments + .into_iter() + .map(project_ticket_role_assignment_record) + .collect(), + })) + }) + .await } async fn compensate_manual_ticket_worker_binding( @@ -13343,6 +14165,32 @@ async fn start_manual_ticket_worker_assignment( record: &TicketRoleAssignmentRecord, operation_id: &str, selected: &[server_api::TicketWorkerAttachmentBinding], + delegation: Option<&TicketStartDelegation>, +) -> ApiResult { + let api = api.clone(); + let record = record.clone(); + let operation_id = operation_id.to_owned(); + let selected = selected.to_vec(); + let delegation = delegation.cloned(); + run_host_workflow(api.host_executors.host()?, async move { + start_manual_ticket_worker_assignment_on_host( + &api, + &record, + &operation_id, + &selected, + delegation.as_ref(), + ) + .await + }) + .await +} + +async fn start_manual_ticket_worker_assignment_on_host( + api: &WorkspaceApi, + record: &TicketRoleAssignmentRecord, + operation_id: &str, + selected: &[server_api::TicketWorkerAttachmentBinding], + delegation: Option<&TicketStartDelegation>, ) -> ApiResult { let TicketAssignmentPrincipal::Worker { runtime_id, @@ -13375,6 +14223,20 @@ async fn start_manual_ticket_worker_assignment( .store .get_ticket_assignment_operation(&api.config.workspace_id, operation_id)? { + if delegation.is_some() { + reserve_ticket_start( + api, + operation_id, + &record.ticket_id, + &worker.runtime_id, + Some(&worker.worker_id), + &fingerprint, + &record.assigned_at, + delegation, + )?; + } + api.store + .validate_ticket_assignment_operation_fence(&api.config.workspace_id, operation_id)?; if operation.request_fingerprint.as_deref() != Some(fingerprint.as_str()) { return Err(Error::TicketAssignmentConflict( "manual Worker claim operation was reused with different input".into(), @@ -13470,14 +14332,15 @@ async fn start_manual_ticket_worker_assignment( } let binding = validate_manual_ticket_worker_workdir_binding(api, &worker, &record.ticket_id, &selected)?; - let admitted = api.store.reserve_ticket_assignment_operation( - &api.config.workspace_id, + let admitted = reserve_ticket_start( + api, operation_id, &record.ticket_id, &worker.runtime_id, Some(&worker.worker_id), &fingerprint, &record.assigned_at, + delegation, )?; if !admitted { return Err(Error::TicketAssignmentConflict("manual Worker claim is already admitted; reread its durable result instead of redispatching".into()).into()); @@ -13501,6 +14364,8 @@ async fn start_manual_ticket_worker_assignment( )?; return Err(error.into()); } + api.store + .validate_ticket_assignment_operation_fence(&api.config.workspace_id, operation_id)?; let stopped = api .runtime .stop_worker( @@ -13725,65 +14590,169 @@ async fn finalize_manual_ticket_worker_claim( } } +#[cfg(test)] async fn scoped_set_ticket_assignment( + state: State, + path: AxumPath<(String, String, String)>, + request: Json, +) -> ApiResult> { + apply_set_ticket_assignment(state, path, request, "workspace-web".into(), None).await +} + +fn authenticated_assignment_source( + api: &WorkspaceApi, + headers: &HeaderMap, + context: &server_api::ServerRequestContext, +) -> Result<( + String, + crate::store::TicketOrchestratorAssignmentSourceContext, +)> { + if let Some(source) = authenticated_ticket_worker_source(headers, context)? { + if !find_workspace_orchestrator(api).is_some_and(|worker| worker.worker == source) { + return Err(Error::WorkspacePermissionDenied( + "Ticket assignment mutations require the registered current Workspace Orchestrator" + .into(), + )); + } + let authentication_binding_id = context + .runtime_source + .as_ref() + .map(|source| source.authentication_binding_id.clone()) + .ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Assignment requires the Runtime binding used to authenticate this Worker" + .into(), + ) + })?; + let actor = format!("worker:{}/{}", source.runtime_id, source.worker_id); + return Ok(( + actor, + crate::store::TicketOrchestratorAssignmentSourceContext::Runtime { + runtime_id: source.runtime_id, + worker_id: source.worker_id, + agent_key: "workspace-orchestrator".into(), + authentication_binding_id, + }, + )); + } + let actor = context.actor.as_ref().ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Ticket assignment mutations require an authenticated actor".into(), + ) + })?; + let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id)) + .map_err(|error| Error::Config(format!("failed to encode assignment actor: {error}")))?; + Ok(( + format!("workspace-user:{identity}"), + crate::store::TicketOrchestratorAssignmentSourceContext::Account { + account_id: actor.account_id.clone(), + user_id: actor.user_id.clone(), + }, + )) +} + +async fn scoped_set_ticket_assignment_with_context( + State(api): State, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + path: AxumPath<(String, String, String)>, + request: Json, +) -> ApiResult> { + validate_workspace_scope(&api, &path.0.0)?; + let source_api = api.clone(); + let context = context.clone(); + let (author, source) = api + .host_executors + .host()? + .run(move || authenticated_assignment_source(&source_api, &headers, &context)) + .await?; + apply_set_ticket_assignment(State(api), path, request, author, Some(source)).await +} + +async fn apply_set_ticket_assignment( State(api): State, AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, Json(request): Json, + assigned_by: String, + source: Option, ) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - let ticket = api.authority.ticket(&id)?; - let role = parse_ticket_assignment_role(&role)?; - let operation_id = require_ticket_assignment_value("operation_id", request.operation_id)?; - let expected_assignment_id = request - .expected_assignment_id - .map(|value| require_ticket_assignment_value("expected_assignment_id", value)) - .transpose()?; - let principal = internal_ticket_assignment_principal(request.principal)?; - let assigned_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); - let record = TicketRoleAssignmentRecord { - workspace_id: workspace_id.clone(), - ticket_id: ticket.id.clone(), - assignment_id: new_id("tasg"), - role, - principal, - assigned_by: "workspace-web".to_string(), - assigned_at, - }; - let assignment = match role { + let prep_api = api.clone(); + let prep_workspace = workspace_id.clone(); + let (ticket, role, operation_id, expected_assignment_id, record, workdir_bindings) = api + .host_executors + .host()? + .run(move || { + let api = &prep_api; + let workspace_id = prep_workspace; + validate_workspace_scope(&api, &workspace_id)?; + let ticket = api.authority.ticket(&id)?; + let role = parse_ticket_assignment_role(&role)?; + let operation_id = + require_ticket_assignment_value("operation_id", request.operation_id)?; + let expected_assignment_id = request + .expected_assignment_id + .map(|value| require_ticket_assignment_value("expected_assignment_id", value)) + .transpose()?; + let principal = internal_ticket_assignment_principal(request.principal)?; + let assigned_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let record = TicketRoleAssignmentRecord { + workspace_id: workspace_id.clone(), + ticket_id: ticket.id.clone(), + assignment_id: new_id("tasg"), + role, + principal, + assigned_by, + assigned_at, + }; + Ok::<_, ApiError>(( + ticket, + role, + operation_id, + expected_assignment_id, + record, + request.workdir_bindings, + )) + }) + .await?; + let (assignment, notification) = match role { TicketAssignmentRole::Orchestrator => { - if !matches!( - ticket.state.as_str(), - state if state == TicketWorkflowState::Planning.as_str() - || state == TicketWorkflowState::Ready.as_str() - ) { - return Err(Error::TicketAssignmentConflict(format!( - "Orchestrator assignment requires planning or ready Ticket; current state is {}", - ticket.state - )) - .into()); - } - if api - .store - .get_active_ticket_role_assignment( - &workspace_id, - &ticket.id, - TicketAssignmentRole::Worker, - )? - .is_some() - { - return Err(Error::TicketAssignmentConflict( - "Orchestrator assignment conflicts with an active Worker assignment" - .to_string(), - ) - .into()); - } - api.store.set_current_ticket_role_assignment( - &record, - expected_assignment_id.as_deref(), - &new_id("tasev"), - &operation_id, - expected_assignment_id.is_some(), - )? + let store = api.store.clone(); + let notification_api = api.clone(); + let record = record.clone(); + let expected = expected_assignment_id.clone(); + let operation_id = operation_id.clone(); + api.store + .db_executor() + .run(move || { + let expected_assignment_id = expected; + let assignment = if let Some(source) = source.as_ref() { + store.set_current_ticket_orchestrator_assignment( + &record, + expected_assignment_id.as_deref(), + &new_id("tasev"), + &operation_id, + expected_assignment_id.is_some(), + source, + ) + } else { + // Only the cfg(test) direct-handler adapter lacks a Backend source. + store.set_current_ticket_role_assignment( + &record, + expected_assignment_id.as_deref(), + &new_id("tasev"), + &operation_id, + expected_assignment_id.is_some(), + ) + }?; + // Dispatch from the accepted SQL owner after commit, even + // when its waiter has gone. The response still observes the + // same best-effort notification completion as before offload. + Ok::<_, Error>(( + assignment, + enqueue_orchestrator_assignment_attention(¬ification_api), + )) + }) + .await? } TicketAssignmentRole::Worker => { if expected_assignment_id.is_some() { @@ -13793,13 +14762,21 @@ async fn scoped_set_ticket_assignment( ) .into()); } - start_manual_ticket_worker_assignment( - &api, - &record, - &operation_id, - &request.workdir_bindings, + let delegation = ticket_start_delegation( + source.as_ref(), + request.orchestrator_assignment_id.as_deref(), + )?; + ( + start_manual_ticket_worker_assignment( + &api, + &record, + &operation_id, + &workdir_bindings, + delegation.as_ref(), + ) + .await?, + None, ) - .await? } TicketAssignmentRole::Owner | TicketAssignmentRole::Contributor => { return Err(Error::TicketAssignmentConflict( @@ -13809,6 +14786,9 @@ async fn scoped_set_ticket_assignment( .into()); } }; + if let Some(notification) = notification { + let _ = notification.await; + } Ok(Json(server_api::TicketRoleAssignmentMutationResponse { workspace_id, ticket_id: ticket.id, @@ -13816,63 +14796,104 @@ async fn scoped_set_ticket_assignment( })) } +#[cfg(test)] async fn scoped_clear_ticket_assignment( + state: State, + path: AxumPath<(String, String, String)>, + query: Query, +) -> ApiResult> { + apply_clear_ticket_assignment(state, path, query, "workspace-web".into(), None).await +} + +async fn scoped_clear_ticket_assignment_with_context( + State(api): State, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + path: AxumPath<(String, String, String)>, + query: Query, +) -> ApiResult> { + validate_workspace_scope(&api, &path.0.0)?; + let source_api = api.clone(); + let context = context.clone(); + let (author, source) = api + .host_executors + .host()? + .run(move || authenticated_assignment_source(&source_api, &headers, &context)) + .await?; + apply_clear_ticket_assignment(State(api), path, query, author, Some(source)).await +} + +async fn apply_clear_ticket_assignment( State(api): State, AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, Query(query): Query, + author: String, + source: Option, ) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - let ticket = api.authority.ticket(&id)?; - let role = parse_ticket_assignment_role(&role)?; - let operation_id = query - .operation_id - .map(|value| require_ticket_assignment_value("operation_id", value)) - .transpose()? - .ok_or_else(|| { - Error::TicketAssignmentConflict("unassign requires operation_id".to_string()) - })?; - let assignment_id = query - .assignment_id - .map(|value| require_ticket_assignment_value("assignment_id", value)) - .transpose()? - .ok_or_else(|| { - Error::TicketAssignmentConflict("unassign requires assignment_id".to_string()) - })?; - if matches!( - ticket.state.as_str(), - state if state == TicketWorkflowState::Queued.as_str() - || state == TicketWorkflowState::InProgress.as_str() - ) { - return Err(Error::TicketAssignmentConflict(format!( - "cannot unassign role `{}` while Ticket is {}; rescope through a guarded lifecycle operation", - role.as_str(), - ticket.state - )) - .into()); - } - let cleared = api.store.clear_current_ticket_role_assignment( - &workspace_id, - &ticket.id, - role, - &assignment_id, - &new_id("tasev"), - &operation_id, - "workspace-web", - &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - Some("role assignment removed from Ticket detail"), - )?; - if !cleared { - return Err(Error::TicketAssignmentConflict(format!( - "assignment `{assignment_id}` is not current for role `{}`", - role.as_str() - )) - .into()); - } - Ok(Json(server_api::TicketRoleAssignmentMutationResponse { - workspace_id, - ticket_id: ticket.id, - assignment: None, - })) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let ticket = api.authority.ticket(&id)?; + let role = parse_ticket_assignment_role(&role)?; + let operation_id = query + .operation_id + .map(|value| require_ticket_assignment_value("operation_id", value)) + .transpose()? + .ok_or_else(|| { + Error::TicketAssignmentConflict("unassign requires operation_id".to_string()) + })?; + let assignment_id = query + .assignment_id + .map(|value| require_ticket_assignment_value("assignment_id", value)) + .transpose()? + .ok_or_else(|| { + Error::TicketAssignmentConflict("unassign requires assignment_id".to_string()) + })?; + let event_id = new_id("tasev"); + let occurred_at = Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true); + let reason = Some("role assignment removed from Ticket detail"); + let cleared = if role == TicketAssignmentRole::Orchestrator + && let Some(source) = source.as_ref() + { + api.store.clear_current_ticket_orchestrator_assignment( + &workspace_id, + &ticket.id, + &assignment_id, + &event_id, + &operation_id, + &author, + &occurred_at, + reason, + source, + )? + } else { + api.store.clear_current_ticket_role_assignment( + &workspace_id, + &ticket.id, + role, + &assignment_id, + &event_id, + &operation_id, + &author, + &occurred_at, + reason, + )? + }; + if !cleared { + return Err(Error::TicketAssignmentConflict(format!( + "assignment `{assignment_id}` is not current for role `{}`", + role.as_str() + )) + .into()); + } + Ok(Json(server_api::TicketRoleAssignmentMutationResponse { + workspace_id, + ticket_id: ticket.id, + assignment: None, + })) + }) + .await } async fn scoped_cancel_ticket_implementation( @@ -13881,72 +14902,65 @@ async fn scoped_cancel_ticket_implementation( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let ticket = api.authority.ticket(&path.id)?; - let operation_id = require_ticket_assignment_value("operation_id", request.operation_id)?; - let assignment_id = require_ticket_assignment_value("assignment_id", request.assignment_id)?; - let reason = require_ticket_assignment_value("reason", request.reason)?; - if reason.len() > 512 { - return Err(Error::InvalidInput( - "implementation cancellation reason must be at most 512 bytes".to_string(), - ) - .into()); - } + // Runtime cancel and session cleanup are mixed work. One accepted Host + // owner retains authority through the whole cancellation transaction/detail. + run_host_workflow(api.host_executors.host()?, async move { + let ticket = api.authority.ticket(&path.id)?; + let operation_id = require_ticket_assignment_value("operation_id", request.operation_id)?; + let assignment_id = require_ticket_assignment_value("assignment_id", request.assignment_id)?; + let reason = require_ticket_assignment_value("reason", request.reason)?; + if reason.len() > 512 { + return Err(Error::InvalidInput( + "implementation cancellation reason must be at most 512 bytes".to_string(), + ) + .into()); + } - if !matches!( - ticket.state.as_str(), - state if state == TicketWorkflowState::InProgress.as_str() - || state == TicketWorkflowState::Ready.as_str() - ) { - return Err(Error::TicketAssignmentConflict(format!( - "implementation cancellation requires an inprogress Ticket; current state is {}", - ticket.state - )) - .into()); - } - - let current = api.store.get_active_ticket_role_assignment( - &path.workspace_id, - &ticket.id, - TicketAssignmentRole::Worker, - )?; - if let Some(assignment) = current.filter(|value| value.assignment_id == assignment_id) - && let TicketAssignmentPrincipal::Worker { - runtime_id, - worker_id, - } = assignment.principal - { - if api - .store - .get_ticket_assignment_operation(&path.workspace_id, &operation_id)? - .is_some() + let current = api.store.get_active_ticket_role_assignment( + &path.workspace_id, + &ticket.id, + TicketAssignmentRole::Worker, + )?; + if let Some(assignment) = current.filter(|value| value.assignment_id == assignment_id) + && let TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } = assignment.principal { + if api + .store + .get_ticket_assignment_operation(&path.workspace_id, &operation_id)? + .is_some() + { + return Err(Error::TicketAssignmentConflict(format!( + "operation `{operation_id}` was already used for another Ticket assignment mutation" + )) + .into()); + } + let worker = RuntimeWorkerRef::new(runtime_id, worker_id); + cancel_ticket_worker(&api, &worker, &reason).await?; + } + + let cancelled = api.store.cancel_current_ticket_worker_assignment( + &path.workspace_id, + &ticket.id, + &assignment_id, + &new_id("tasev"), + &new_id("tev"), + &operation_id, + "workspace-web", + &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + &reason, + )?; + if !cancelled { return Err(Error::TicketAssignmentConflict(format!( - "operation `{operation_id}` was already used for another Ticket assignment mutation" + "assignment `{assignment_id}` is not the current Coder implementation" )) .into()); } - let worker = RuntimeWorkerRef::new(runtime_id, worker_id); - cancel_ticket_worker(&api, &worker, &reason).await?; - } - - let cancelled = api.store.cancel_current_ticket_worker_assignment( - &path.workspace_id, - &ticket.id, - &assignment_id, - &new_id("tasev"), - &new_id("tev"), - &operation_id, - "workspace-web", - &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), - &reason, - )?; - if !cancelled { - return Err(Error::TicketAssignmentConflict(format!( - "assignment `{assignment_id}` is not the current Coder implementation" - )) - .into()); - } - browser_ticket_detail(&api, &ticket.id) + browser_ticket_detail(&api, &ticket.id) + }) + .await } async fn cancel_ticket_worker( @@ -13981,24 +14995,132 @@ async fn cancel_ticket_worker( Ok(()) } +/// Observation-bound delegation for a new Ticket execution operation. This is +/// distinct from self-reported handling and from the Worker work assignment. +type TicketStartDelegation = (String, ticket::TicketAssignmentTrackingSourceContext); + +fn ticket_start_delegation( + source: Option<&crate::store::TicketOrchestratorAssignmentSourceContext>, + observed_assignment_id: Option<&str>, +) -> Result> { + use crate::store::TicketOrchestratorAssignmentSourceContext; + match source { + Some(TicketOrchestratorAssignmentSourceContext::Runtime { + runtime_id, + worker_id, + agent_key, + authentication_binding_id, + }) => { + let assignment_id = observed_assignment_id.filter(|id| !id.trim().is_empty()) + .ok_or_else(|| Error::TicketAssignmentConflict("Orchestrator Ticket start requires the observed orchestrator_assignment_id".into()))?; + if assignment_id.len() > 1024 || assignment_id.chars().any(char::is_control) { + return Err(Error::InvalidInput( + "orchestrator_assignment_id must be control-free and at most 1024 bytes".into(), + )); + } + Ok(Some(( + assignment_id.to_owned(), + ticket::TicketAssignmentTrackingSourceContext { + actor: format!("worker:{runtime_id}/{worker_id}"), + runtime_id: runtime_id.clone(), + worker_id: worker_id.clone(), + agent_key: agent_key.clone(), + authentication_binding_id: authentication_binding_id.clone(), + }, + ))) + } + _ if observed_assignment_id.is_some() => Err(Error::WorkspacePermissionDenied( + "Only the authenticated current Orchestrator may start under a delegation".into(), + )), + _ => Ok(None), + } +} + +fn ticket_spawn_delegation( + api: &WorkspaceApi, + headers: &HeaderMap, + context: Option<&server_api::ServerRequestContext>, + observed_assignment_id: Option<&str>, +) -> Result> { + if let Some(context) = context { + // Generic authenticated Worker/manual starts are not implicit delegation. + // Only the registered Orchestrator starts under its observed assignment. + let worker = authenticated_ticket_worker_source(headers, context)?; + if let Some(worker) = worker.as_ref() { + api.store + .require_current_worker_singleton_owner(&api.config.workspace_id, worker)?; + if find_workspace_orchestrator(api).is_some_and(|current| current.worker == *worker) { + let (_, source) = authenticated_assignment_source(api, headers, context)?; + return ticket_start_delegation(Some(&source), observed_assignment_id); + } + } + ticket_start_delegation(None, observed_assignment_id) + } else { + // Only direct test adapters omit middleware context. Production routes + // must preserve the binding that actually authenticated the request. + #[cfg(test)] + { + ticket_start_delegation(None, observed_assignment_id) + } + #[cfg(not(test))] + { + Err(Error::WorkspacePermissionDenied( + "Ticket start requires Backend-authenticated context".into(), + )) + } + } +} + +fn reserve_ticket_start( + api: &WorkspaceApi, + operation_id: &str, + ticket_id: &str, + runtime_id: &str, + worker_id: Option<&str>, + fingerprint: &str, + created_at: &str, + delegation: Option<&TicketStartDelegation>, +) -> Result { + if let Some((assignment_id, source)) = delegation { + api.store + .reserve_ticket_assignment_operation_for_orchestrator( + &api.config.workspace_id, + operation_id, + ticket_id, + runtime_id, + worker_id, + fingerprint, + created_at, + assignment_id, + source, + ) + } else { + api.store.reserve_ticket_assignment_operation( + &api.config.workspace_id, + operation_id, + ticket_id, + runtime_id, + worker_id, + fingerprint, + created_at, + ) + } +} + fn validate_ticket_assignment_state( api: &WorkspaceApi, assignment: &WorkerTicketAssignmentRequest, ) -> Result<()> { let ticket = api.authority.ticket(&assignment.ticket_id)?; - if matches!(ticket.state.as_str(), "done" | "closed") { + if ticket.state == server_api::BrowserTicketState::Closed { return Err(Error::TicketAssignmentConflict(format!( "Ticket {} is {}; reopen through an explicit lifecycle decision before starting work", - ticket.id, ticket.state + ticket.id, + ticket.state.as_str() ))); } - let dependencies = - browser_ticket_backend(api)?.dependency_check(TicketIdOrSlug::Id(ticket.id))?; - if !dependencies.blockers.is_empty() { - return Err(Error::TicketAssignmentConflict( - "Ticket work is blocked by unresolved relations".into(), - )); - } + // Dependencies remain diagnostics for the caller's partial-work decision; + // they are not authority, resource occupancy, or execution reservations. Ok(()) } @@ -14170,25 +15292,14 @@ fn accept_queued_ticket_after_worker_spawn( assignment: &crate::hosts::WorkerTicketAssignmentRequest, ) -> Result<()> { let ticket = api.authority.ticket(&assignment.ticket_id)?; - if ticket.state == TicketWorkflowState::InProgress.as_str() { - return Ok(()); - } - if !matches!(ticket.state.as_str(), "planning" | "ready" | "queued") { + if ticket.state != server_api::BrowserTicketState::Open { return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} left queued state before Ticket Worker spawn acceptance; current state is {}", - ticket.id, ticket.state + "Ticket {} was closed before explicit Worker spawn acceptance", + ticket.id ))); } - let mut change = TicketStateChange::new( - ticket.state.as_str(), - TicketWorkflowState::InProgress.as_str(), - "Ticket Worker spawn, assignment, and initial input were durably accepted", - "", - ); - change.author = Some("workspace-orchestrator".to_string()); - browser_ticket_backend(api)? - .set_workflow_state(TicketIdOrSlug::Id(ticket.id), change) - .map_err(Error::from)?; + // Claim/assignment receipts own execution acceptance. Never manufacture a + // Ticket phase or revive a released identity from its retained assignee. Ok(()) } @@ -14306,92 +15417,54 @@ async fn scoped_edit_ticket_item( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let mut targets = request.target.map(|target| match target { - server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set { - targets: targets - .into_iter() - .map(|target| ticket::TicketTarget { - repository_key: target.repository_key, - ref_selector: target.ref_selector, - access: target.access, - }) - .collect(), - }, - server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear, - }); - if let Some(TicketTargetsEdit::Set { targets }) = targets.as_mut() { - validate_ticket_target_repository_keys(&api, &path.workspace_id, targets)?; - } - browser_ticket_backend(&api)? - .edit_item( - TicketIdOrSlug::Id(path.id.clone()), - TicketItemEdit { - title: request.title, - body: request.body.map(MarkdownText::new), - body_replacement: match (request.old_string, request.new_string) { - (Some(old_string), Some(new_string)) => Some(TicketBodyReplacement { - old_string, - new_string, - replace_all: request.replace_all, - }), - (None, None) => None, - _ => { - return Err(settings_bad_request( - "invalid_ticket_edit_replacement", - "old_string and new_string must be provided together", - )); - } + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let mut targets = request.target.map(|target| match target { + server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set { + targets: targets + .into_iter() + .map(|target| ticket::TicketTarget { + repository_key: target.repository_key, + ref_selector: target.ref_selector, + access: target.access, + }) + .collect(), }, - targets, - author: request.author, - }, - ) - .map_err(Error::from)?; - browser_ticket_detail(&api, &path.id) -} - -fn browser_ticket_workflow_state( - state: server_api::BrowserTicketWorkflowState, -) -> TicketWorkflowState { - match state { - server_api::BrowserTicketWorkflowState::Planning => TicketWorkflowState::Planning, - server_api::BrowserTicketWorkflowState::Ready => TicketWorkflowState::Ready, - server_api::BrowserTicketWorkflowState::Queued => TicketWorkflowState::Queued, - server_api::BrowserTicketWorkflowState::Inprogress => TicketWorkflowState::InProgress, - server_api::BrowserTicketWorkflowState::Done => TicketWorkflowState::Done, - server_api::BrowserTicketWorkflowState::Closed => TicketWorkflowState::Closed, - } -} - -async fn scoped_transition_ticket_state( - State(api): State, - headers: HeaderMap, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = browser_ticket_workflow_state(request.state); - let reason = match request.body.filter(|body| !body.is_empty()) { - Some(body) => format!("{}\n\n{body}", request.reason), - None => request.reason, - }; - let _ = scoped_ticket_state_update( - State(api.clone()), - headers, - AxumPath((path.workspace_id.clone(), path.id.clone())), - Json(ticket::TicketStateUpdate { - state, - operation_key: request.operation_key, - expected_content_digest: request.expected_content_digest, - expected_state: browser_ticket_workflow_state(request.expected_state), - reason, - references: Vec::new(), - author: Some("workspace-user".into()), - }), - ) - .await?; - browser_ticket_detail(&api, &path.id) + server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear, + }); + if let Some(TicketTargetsEdit::Set { targets }) = targets.as_mut() { + validate_ticket_target_repository_keys(&api, &path.workspace_id, targets)?; + } + browser_ticket_backend(&api)? + .edit_item( + TicketIdOrSlug::Id(path.id.clone()), + TicketItemEdit { + title: request.title, + body: request.body.map(MarkdownText::new), + body_replacement: match (request.old_string, request.new_string) { + (Some(old_string), Some(new_string)) => Some(TicketBodyReplacement { + old_string, + new_string, + replace_all: request.replace_all, + }), + (None, None) => None, + _ => { + return Err(settings_bad_request( + "invalid_ticket_edit_replacement", + "old_string and new_string must be provided together", + )); + } + }, + targets, + author: request.author, + }, + ) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) + }) + .await } async fn scoped_append_ticket_event( @@ -14399,21 +15472,26 @@ async fn scoped_append_ticket_event( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let role = match request.role { - server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment, - server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan, - server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision, - server_api::BrowserTicketThreadRole::ImplementationReport => { - TicketEventKind::ImplementationReport - } - }; - let mut event = NewTicketEvent::new(role, request.body); - event.author = request.author; - browser_ticket_backend(&api)? - .add_event(TicketIdOrSlug::Id(path.id.clone()), event) - .map_err(Error::from)?; - browser_ticket_detail(&api, &path.id) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let role = match request.role { + server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment, + server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan, + server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision, + server_api::BrowserTicketThreadRole::ImplementationReport => { + TicketEventKind::ImplementationReport + } + }; + let mut event = NewTicketEvent::new(role, request.body); + event.author = request.author; + browser_ticket_backend(&api)? + .add_event(TicketIdOrSlug::Id(path.id.clone()), event) + .map_err(Error::from)?; + browser_ticket_detail(&api, &path.id) + }) + .await } async fn scoped_mark_ticket_ready_from_browser( @@ -14423,14 +15501,32 @@ async fn scoped_mark_ticket_ready_from_browser( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let _ = scoped_mark_ticket_ready( - State(api.clone()), - AxumPath((path.workspace_id.clone(), path.id.clone())), - headers, - Json(request), - ) - .await?; - browser_ticket_detail(&api, &path.id) + api.host_executors + .host()? + .run(move || { + let result = execute_ticket_rest_operation_on_host( + &api, + &path.workspace_id, + headers, + None, + TicketBackendOperation::MarkReady { + id: TicketIdOrSlug::Query(path.id.clone()), + request: ticket::TicketMarkReady { + operation_key: request.operation_key, + reason: request.reason, + author: None, + intake_summary: request.intake_summary, + }, + }, + )?; + // Preserve result validation before projecting the committed detail. + let _ = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::Ticket(ticket) => Some(ticket), + _ => None, + })?; + browser_ticket_detail(&api, &path.id) + }) + .await } async fn scoped_queue_ticket( @@ -14456,36 +15552,9 @@ async fn scoped_queue_ticket( }) } -async fn scoped_close_ticket( - State(api): State, - headers: HeaderMap, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let _ = scoped_ticket_state_update( - State(api.clone()), - headers, - AxumPath((path.workspace_id.clone(), path.id.clone())), - Json(ticket::TicketStateUpdate { - state: TicketWorkflowState::Closed, - operation_key: request.operation_key, - expected_content_digest: request.expected_content_digest, - expected_state: browser_ticket_workflow_state(request.expected_state), - reason: request.resolution, - references: Vec::new(), - author: Some("workspace-user".into()), - }), - ) - .await?; - browser_ticket_detail(&api, &path.id) -} - fn generic_ticket_state_change(operation: &TicketBackendOperation) -> Option<&TicketStateChange> { match operation { - TicketBackendOperation::SetWorkflowState { change, .. } - | TicketBackendOperation::SetStateField { change, .. } - | TicketBackendOperation::AddStateChanged { change, .. } => Some(change), + TicketBackendOperation::AddStateChanged { change, .. } => Some(change), _ => None, } } @@ -14619,26 +15688,81 @@ fn schedule_ticket_item_check( } }; let api = api.clone(); - tokio::task::spawn_blocking(move || { - if let Err(error) = api.dispatch_backend_job_reservation(reservation) { - tracing::warn!( - job_id = %request.job_id, - ticket_input_digest = ?request.input_digest(), - %error, - "asynchronous Ticket item checker dispatch failed" - ); - api.drain_reserved_backend_jobs(); - } + let admission = api.host_executors.host().and_then(|executor| { + executor.dispatch(move || { + if let Err(error) = api.dispatch_backend_job_reservation(reservation) { + tracing::warn!( + job_id = %request.job_id, + ticket_input_digest = ?request.input_digest(), + %error, + "asynchronous Ticket item checker dispatch failed" + ); + if !matches!(error, Error::DbOperationOutcomeUnknown) { + api.drain_reserved_backend_jobs(); + } + } + }) }); + if let Err(error) = admission { + tracing::warn!(%error, "Ticket checker was not admitted; its reservation remains durable"); + } } async fn execute_ticket_rest_operation( api: &WorkspaceApi, workspace_id: &str, headers: HeaderMap, - mut operation: TicketBackendOperation, + operation: TicketBackendOperation, +) -> ApiResult { + execute_ticket_rest_operation_with_context(api, workspace_id, headers, None, operation).await +} + +async fn execute_ticket_rest_operation_with_context( + api: &WorkspaceApi, + workspace_id: &str, + headers: HeaderMap, + context: Option<&server_api::ServerRequestContext>, + operation: TicketBackendOperation, ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; + if let TicketBackendOperation::UpdateCase { ticket, request } = operation { + let context = context.ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Ticket case decisions require verified request context".into(), + ) + })?; + let Json(updated) = scoped_ticket_case_update_with_context( + State(api.clone()), + headers, + context, + AxumPath((workspace_id.to_string(), ticket)), + Json(request), + ) + .await?; + return Ok(TicketBackendOperationResult::Ticket(updated)); + } + let job_api = api.clone(); + let workspace_id = workspace_id.to_owned(); + let context = context.cloned(); + api.host_executors + .host()? + .run(move || { + let api = &job_api; + let workspace_id = workspace_id.as_str(); + let context = context.as_ref(); + execute_ticket_rest_operation_on_host(api, workspace_id, headers, context, operation) + }) + .await +} + +// Already-owned callers must not re-admit to the occupied Host lane. +fn execute_ticket_rest_operation_on_host( + api: &WorkspaceApi, + workspace_id: &str, + headers: HeaderMap, + context: Option<&server_api::ServerRequestContext>, + mut operation: TicketBackendOperation, +) -> ApiResult { validate_ticket_operation_repository_keys(api, workspace_id, &operation)?; let mut backend = SqliteTicketBackend::open_verified( api.config.database_path.clone(), @@ -14654,15 +15778,38 @@ async fn execute_ticket_rest_operation( // Human clients are authorized by the Workspace route boundary. Runtime-forwarded Worker // calls carry source headers; when either header is present, the complete pair is required // and authenticated before source attribution is attached. - let source = optional_worker_mutation_source(api, workspace_id, &headers)?; + let hinted_source = optional_worker_mutation_source(api, workspace_id, &headers)?; + // The signed Workspace request carries verified identity in the middleware + // extension, independently of optional legacy source hints. Classification + // receipts and audit must not collapse distinct Workers into a human/no-source + // identity just because those redundant headers are absent. + let verified_source = context + .and_then(|context| context.runtime_source.as_ref()) + .map(|source| { + let worker_id = source.worker_id.as_ref().ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Classification requests require a verified Runtime Worker identity".into(), + ) + })?; + Ok::<_, Error>(RuntimeWorkerRef::new(&source.runtime_id, worker_id)) + }) + .transpose()?; + if let (Some(verified), Some(hinted)) = (&verified_source, &hinted_source) + && verified != hinted + { + return Err(Error::WorkspacePermissionDenied( + "Classification source hints do not match the verified Runtime Worker".into(), + ) + .into()); + } + let source = verified_source.or(hinted_source); validate_ticket_repository_operation(api, &operation)?; let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok()); if let Some(source) = source.as_ref() && (generic_ticket_state_change(&operation).is_some() || matches!( operation, - TicketBackendOperation::Close { .. } - | TicketBackendOperation::QueueReady { .. } + TicketBackendOperation::QueueReady { .. } | TicketBackendOperation::MarkReady { .. } )) { @@ -14684,6 +15831,12 @@ async fn execute_ticket_rest_operation( .map(|ticket| ticket.meta.workflow_state.as_str().to_string()) .unwrap_or_else(|| ticket_operation_initial_state(&operation)); let mut event_attributes = BTreeMap::new(); + if source.is_none() + && let Some(actor) = context.and_then(|context| context.actor.as_ref()) + { + event_attributes.insert("source_account_id".into(), actor.account_id.clone()); + event_attributes.insert("source_user_id".into(), actor.user_id.clone()); + } if matches!(operation, TicketBackendOperation::QueueReady { .. }) { let ticket = before.as_ref().ok_or_else(|| { Error::TicketAssignmentConflict( @@ -14731,53 +15884,10 @@ async fn execute_ticket_rest_operation( backend = backend.with_event_attributes(event_attributes); } - // Older transport-shaped state/close calls are adapters to the same Ticket - // decision boundary, never alternative completion authorities. - if let Some(before) = before.as_ref() { - let decision = match &operation { - TicketBackendOperation::SetWorkflowState { change, .. } - | TicketBackendOperation::AddStateChanged { change, .. } => Some(( - TicketWorkflowState::parse(&change.to) - .ok_or_else(|| Error::InvalidInput("invalid Ticket state".into()))?, - TicketWorkflowState::parse(&change.from) - .ok_or_else(|| Error::InvalidInput("invalid expected Ticket state".into()))?, - if change.body.as_str().is_empty() { - change.reason.clone() - } else { - format!("{}\n\n{}", change.reason, change.body.as_str()) - }, - change.author.clone(), - change.references.clone(), - )), - TicketBackendOperation::Close { resolution, .. } => Some(( - TicketWorkflowState::Closed, - before.meta.workflow_state, - resolution.as_str().to_string(), - source - .as_ref() - .map(|source| format!("worker:{}/{}", source.runtime_id, source.worker_id)) - .or_else(|| Some("workspace-user".into())), - Vec::new(), - )), - _ => None, - }; - if let Some((state, expected_state, reason, author, references)) = decision { - backend - .update_state( - &before.meta.id, - ticket::TicketStateUpdate { - operation_key: new_id("tdecision"), - expected_content_digest: ticket::ticket_content_digest(before), - expected_state, - state, - reason, - author, - references, - }, - ) - .map_err(Error::from)?; - return Ok(TicketBackendOperationResult::Unit); - } + if generic_ticket_state_change(&operation).is_some() { + return Err(Error::InvalidInput( + "Ticket case decisions require TicketCaseUpdate with explicit content/state/closure-key CAS and replay key".into() + ).into()); } let (result, created_ticket) = match operation { @@ -14814,15 +15924,16 @@ async fn execute_ticket_rest_operation( None, ), }; - if is_mutation { + // Classification has no routing or execution side effects. + if is_mutation && !matches!(operation_kind, "mutate_workspace_tag" | "set_ticket_tags") { if let TicketBackendOperationResult::QueueOutcome(outcome) = &result { for ticket_id in &outcome.queued_tickets { notify_ticket_recipients( api, workspace_id, ticket_id, - TicketWorkflowState::Ready.as_str(), - TicketWorkflowState::Queued.as_str(), + "open", + "open", source.clone(), ); } @@ -15045,22 +16156,6 @@ async fn scoped_add_ticket_thread_event( ticket_rest_unit(result) } -async fn scoped_add_ticket_state_change( - State(api): State, - AxumPath((workspace_id, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult { - let _ = scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, id)), - Json(request), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - async fn scoped_add_ticket_intake_summary( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, @@ -15089,41 +16184,6 @@ struct InternalTicketMarkReadyRequest { intake_summary: Option, } -async fn scoped_set_ticket_state_field( - State(api): State, - AxumPath((workspace_id, id, field)): AxumPath<(String, String, String)>, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult { - if field != "state" { - return Err(Error::InvalidInput("unsupported Ticket state field".into()).into()); - } - let _ = scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, id)), - Json(request), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -async fn scoped_set_ticket_workflow_state( - State(api): State, - AxumPath((workspace_id, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult { - let _ = scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, id)), - Json(request), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - async fn scoped_mark_ticket_ready( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, @@ -15386,7 +16446,7 @@ fn remap_source_ref_error(error: ApiError) -> ApiError { } /// Observe the provider-published source commit through the assigned Runtime, not -/// a Server-local checkout. Open/list/show/review/complete paths call this at the +/// a Server-local checkout. Open/list/show/review paths call this at the /// point they need current source evidence; provider failure remains unavailable. fn observe_published_source_ref( api: &WorkspaceApi, @@ -15862,6 +16922,8 @@ async fn scoped_list_merge_requests( AxumPath(workspace_id): AxumPath, Query(query): Query, ) -> ApiResult> { + api.host_executors.host()?.run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; let ticket_id = query @@ -15970,6 +17032,8 @@ async fn scoped_list_merge_requests( items, next_cursor: page.next_cursor, })) + + }).await } fn merge_ref_diagnostic(error: ApiError) -> MergeRequestRefDiagnostic { @@ -16075,159 +17139,169 @@ async fn scoped_show_merge_request( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Query(query): Query, ) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; - require_workspace_access(&workspace_id, &api)?; - let store = merge_request_store(&api, &workspace_id)?; - let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?; - let integrated_refs = integrated_merge_refs(&mr); - mr.thread = store.thread_page_by_id( - &workspace_id, - &merge_request_id, - query.after, - query.limit.unwrap_or(100), - )?; - let ticket_id = mr.ticket_ids.first().ok_or_else(|| { - Error::MergeRequest(merge_request::MergeRequestError::Conflict( - "Merge Request has no linked Ticket".into(), - )) - })?; - let assignment = api - .store - .get_active_ticket_worker_assignment(&workspace_id, ticket_id)?; - let (source, target) = if let Some(refs) = integrated_refs { - refs - } else { - let source = match (mr.selector_from.as_deref(), assignment.as_ref()) { - (Some(selector), Some(assignment)) => { - match observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - selector, - ) { - Ok(observation) => merge_ref_response(observation), - Err(error) => unknown_merge_ref_response(error), - } - } - (Some(_), None) => unknown_merge_ref( - "source_ref_runtime_unavailable", - "No current Coder Runtime is available to observe the source ref", - ), - (None, _) => server_api::MergeRequestRefResponse { - status: "requires_repair".into(), - resolved_ref: None, - observed_at: Utc::now().to_rfc3339(), - diagnostic: None, - }, - }; - let target = match assignment.as_ref() { - Some(assignment) => match observe_published_merge_ref( - &api, + api.host_executors + .host()? + .run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; + require_workspace_access(&workspace_id, &api)?; + let store = merge_request_store(&api, &workspace_id)?; + let mut mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let integrated_refs = integrated_merge_refs(&mr); + mr.thread = store.thread_page_by_id( &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - &mr.selector_to, - ) { - Ok(observation) => merge_ref_response(observation), - Err(error) => unknown_merge_ref_response(error), - }, - None => unknown_merge_ref( - "target_ref_runtime_unavailable", - "No current Coder Runtime is available to observe the target ref", - ), - }; - (source, target) - }; - let linked_tickets = mr - .ticket_ids - .iter() - .map(|ticket_id| { - Ok(server_api::MergeRequestLinkedTicketResponse { - ticket_id: ticket_id.clone(), - key: api.store.resource_key( - &workspace_id, - WorkspaceResourceKind::Ticket, - ticket_id, - )?, - }) + &merge_request_id, + query.after, + query.limit.unwrap_or(100), + )?; + let ticket_id = mr.ticket_ids.first().ok_or_else(|| { + Error::MergeRequest(merge_request::MergeRequestError::Conflict( + "Merge Request has no linked Ticket".into(), + )) + })?; + let assignment = api + .store + .get_active_ticket_worker_assignment(&workspace_id, ticket_id)?; + let (source, target) = if let Some(refs) = integrated_refs { + refs + } else { + let source = match (mr.selector_from.as_deref(), assignment.as_ref()) { + (Some(selector), Some(assignment)) => { + match observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + selector, + ) { + Ok(observation) => merge_ref_response(observation), + Err(error) => unknown_merge_ref_response(error), + } + } + (Some(_), None) => unknown_merge_ref( + "source_ref_runtime_unavailable", + "No current Coder Runtime is available to observe the source ref", + ), + (None, _) => server_api::MergeRequestRefResponse { + status: "requires_repair".into(), + resolved_ref: None, + observed_at: Utc::now().to_rfc3339(), + diagnostic: None, + }, + }; + let target = match assignment.as_ref() { + Some(assignment) => match observe_published_merge_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + &mr.selector_to, + ) { + Ok(observation) => merge_ref_response(observation), + Err(error) => unknown_merge_ref_response(error), + }, + None => unknown_merge_ref( + "target_ref_runtime_unavailable", + "No current Coder Runtime is available to observe the target ref", + ), + }; + (source, target) + }; + let linked_tickets = mr + .ticket_ids + .iter() + .map(|ticket_id| { + Ok(server_api::MergeRequestLinkedTicketResponse { + ticket_id: ticket_id.clone(), + key: api.store.resource_key( + &workspace_id, + WorkspaceResourceKind::Ticket, + ticket_id, + )?, + }) + }) + .collect::>>()?; + let merge_request = public_merge_request(&api, &workspace_id, mr)?; + Ok(Json(server_api::MergeRequestDetailResponse { + merge_request, + source, + target, + linked_tickets, + })) }) - .collect::>>()?; - let merge_request = public_merge_request(&api, &workspace_id, mr)?; - Ok(Json(server_api::MergeRequestDetailResponse { - merge_request, - source, - target, - linked_tickets, - })) + .await } async fn scoped_merge_request_readiness( State(api): State, AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, ) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; - let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get_by_id(&workspace_id, &merge_request_id)?; - let ticket_id = merge_request_ticket_id(&mr)?; - let assignment = api - .store - .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?; - let (current_subject_ref, source_blocker) = - if mr.state == merge_request::MergeRequestState::Merged { - ( - mr.merged_result() - .ok() - .map(|merge| merge.approved_source_ref.clone()), - None, - ) - } else { - match (mr.selector_from.as_deref(), assignment) { - (Some(selector), Some(assignment)) => match observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - selector, - ) { - Ok(observation) => (Some(observation.resolved_ref), None), - Err(error) => { - let diagnostic = merge_ref_diagnostic(error); - let blocker = source_ref_readiness_blocker(&diagnostic.code); - (None, Some(blocker.to_string())) + api.host_executors + .host()? + .run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; + let assignment = api + .store + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)?; + let (current_subject_ref, source_blocker) = + if mr.state == merge_request::MergeRequestState::Merged { + ( + mr.merged_result() + .ok() + .map(|merge| merge.approved_source_ref.clone()), + None, + ) + } else { + match (mr.selector_from.as_deref(), assignment) { + (Some(selector), Some(assignment)) => match observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + selector, + ) { + Ok(observation) => (Some(observation.resolved_ref), None), + Err(error) => { + let diagnostic = merge_ref_diagnostic(error); + let blocker = source_ref_readiness_blocker(&diagnostic.code); + (None, Some(blocker.to_string())) + } + }, + (Some(_), None) => (None, Some("source_ref_unavailable".to_string())), + (None, _) => (None, None), } + }; + let mut report = store.readiness(merge_request::ReadinessCheck { + merge_request_id, + ticket_id, + current_subject_ref, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: String::new(), + worker_id: String::new(), + assignment_id: String::new(), }, - (Some(_), None) => (None, Some("source_ref_unavailable".to_string())), - (None, _) => (None, None), + })?; + if let Some(blocker) = source_blocker { + report + .blockers + .retain(|current| current != "selector_unresolved"); + if !report.blockers.contains(&blocker) { + report.blockers.push(blocker); + } + report.ready = false; } - }; - let mut report = store.readiness(merge_request::ReadinessCheck { - merge_request_id, - ticket_id, - current_subject_ref, - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: mr.repository_id, - runtime_id: String::new(), - worker_id: String::new(), - assignment_id: String::new(), - }, - })?; - if let Some(blocker) = source_blocker { - report - .blockers - .retain(|current| current != "selector_unresolved"); - if !report.blockers.contains(&blocker) { - report.blockers.push(blocker); - } - report.ready = false; - } - Ok(Json(server_api::MergeRequestReadinessResponse { - ready: report.ready, - blockers: report.blockers, - subject_ref: report.subject_ref, - review: report.review.map(public_review_event), - })) + Ok(Json(server_api::MergeRequestReadinessResponse { + ready: report.ready, + blockers: report.blockers, + subject_ref: report.subject_ref, + review: report.review.map(public_review_event), + })) + }) + .await } async fn scoped_open_merge_request( @@ -16236,71 +17310,79 @@ async fn scoped_open_merge_request( AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; - let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; - require_workspace_access(&workspace_id, &api)?; - let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; - let assignment = api - .store - .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into()) - })?; - if assignment.worker.runtime_id != source.runtime_id - || assignment.worker.worker_id != source.worker_id - { - return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not current assignee".into(), - ) - .into()); - } - let repository = api.require_configured_workspace_repository_by_key(&input.repository_key)?; - let repository_id = repository.id; - require_ticket_read_write_target(&api, &ticket_id, &repository_id, &input.selector_to)?; - let source_observation = observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &repository_id, - &input.selector_from, - )?; - require_assigned_workdir_source( - &api, - &assignment, - &repository_id, - &input.selector_from, - &source_observation.resolved_ref, - )?; - observe_published_merge_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &repository_id, - &input.selector_to, - )?; - let merge_request = merge_request_store(&api, &workspace_id)?.open_merge_request( - merge_request::OpenMergeRequest { - merge_request_id: Uuid::now_v7().to_string(), - ticket_id, - repository_id: repository_id.clone(), - selector_from: input.selector_from, - selector_to: input.selector_to, - summary: input.summary, - auth: merge_request::MergeRequestAuth { - workspace_id: workspace_id.clone(), - repository_id, - runtime_id: source.runtime_id, - worker_id: source.worker_id, - assignment_id: assignment.assignment_id, - }, - now: Utc::now(), - }, - )?; - Ok(Json(public_merge_request( - &api, - &workspace_id, - merge_request, - )?)) + api.host_executors + .host()? + .run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; + require_workspace_access(&workspace_id, &api)?; + let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + let assignment = api + .store + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict( + "Ticket has no active Coder work assignment".into(), + ) + })?; + if assignment.worker.runtime_id != source.runtime_id + || assignment.worker.worker_id != source.worker_id + { + return Err(Error::TicketAssignmentConflict( + "authenticated Worker is not current assignee".into(), + ) + .into()); + } + let repository = + api.require_configured_workspace_repository_by_key(&input.repository_key)?; + let repository_id = repository.id; + require_ticket_read_write_target(&api, &ticket_id, &repository_id, &input.selector_to)?; + let source_observation = observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &repository_id, + &input.selector_from, + )?; + require_assigned_workdir_source( + &api, + &assignment, + &repository_id, + &input.selector_from, + &source_observation.resolved_ref, + )?; + observe_published_merge_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &repository_id, + &input.selector_to, + )?; + let merge_request = merge_request_store(&api, &workspace_id)?.open_merge_request( + merge_request::OpenMergeRequest { + merge_request_id: Uuid::now_v7().to_string(), + ticket_id, + repository_id: repository_id.clone(), + selector_from: input.selector_from, + selector_to: input.selector_to, + summary: input.summary, + auth: merge_request::MergeRequestAuth { + workspace_id: workspace_id.clone(), + repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), + }, + )?; + Ok(Json(public_merge_request( + &api, + &workspace_id, + merge_request, + )?)) + }) + .await } async fn scoped_merge_request_thread( @@ -16308,19 +17390,24 @@ async fn scoped_merge_request_thread( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Query(query): Query, ) -> ApiResult>> { - let workspace_id = parse_workspace_id(&workspace_id)?; - let events = merge_request_store(&api, &workspace_id)?.thread_page_by_id( - &workspace_id, - &merge_request_id, - query.after, - query.limit.unwrap_or(100), - )?; - Ok(Json( - events - .into_iter() - .map(public_merge_request_thread_event) - .collect(), - )) + api.host_executors + .host()? + .run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; + let events = merge_request_store(&api, &workspace_id)?.thread_page_by_id( + &workspace_id, + &merge_request_id, + query.after, + query.limit.unwrap_or(100), + )?; + Ok(Json( + events + .into_iter() + .map(public_merge_request_thread_event) + .collect(), + )) + }) + .await } async fn scoped_repair_merge_request_selector( @@ -16336,37 +17423,46 @@ async fn scoped_repair_merge_request_selector( if !input.explicit_confirmation { return Err(Error::BrowserReopenConfirmationRequired.into()); } - let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get_by_id(&workspace_id, &merge_request_id)?; - let ticket_id = merge_request_ticket_id(&mr)?; - let assignment = api - .store - .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into()) - })?; - let resolved_subject_ref = observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - &input.selector_from, - )? - .resolved_ref; - let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom { - workspace_id: workspace_id.clone(), - merge_request_id, - ticket_id, - selector_from: input.selector_from, - resolved_subject_ref, - repaired_by: merge_request::WorkerIdentity { - runtime_id: "browser".into(), - worker_id: "authenticated-user".into(), - }, - reason: input.reason, - now: Utc::now(), - })?; - Ok(Json(public_merge_request(&api, &workspace_id, repaired)?)) + // Provider observation and intact repair/audit SQL share an accepted owner; + // authentication's earlier DB await does not offload this mixed stage. + api.host_executors + .host()? + .run(move || { + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; + let assignment = api + .store + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict( + "Ticket has no active Coder work assignment".into(), + ) + })?; + let resolved_subject_ref = observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + &input.selector_from, + )? + .resolved_ref; + let repaired = store.repair_selector_from(merge_request::RepairSelectorFrom { + workspace_id: workspace_id.clone(), + merge_request_id, + ticket_id, + selector_from: input.selector_from, + resolved_subject_ref, + repaired_by: merge_request::WorkerIdentity { + runtime_id: "browser".into(), + worker_id: "authenticated-user".into(), + }, + reason: input.reason, + now: Utc::now(), + })?; + Ok(Json(public_merge_request(&api, &workspace_id, repaired)?)) + }) + .await } async fn scoped_register_reviewer_child_session( @@ -16375,20 +17471,25 @@ async fn scoped_register_reviewer_child_session( AxumPath(workspace_id): AxumPath, Json(input): Json, ) -> ApiResult { - let workspace_id = parse_workspace_id(&workspace_id)?; - require_workspace_access(&workspace_id, &api)?; - let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; - merge_request_store(&api, &workspace_id)?.register_reviewer_child_session( - merge_request::RegisterReviewerChildSession { - workspace_id, - parent_runtime_id: source.runtime_id, - parent_worker_id: source.worker_id, - child_session_id: input.child_session_id, - reviewer_profile: "builtin:reviewer".into(), - now: Utc::now(), - }, - )?; - Ok(StatusCode::NO_CONTENT) + api.host_executors + .host()? + .run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; + require_workspace_access(&workspace_id, &api)?; + let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + merge_request_store(&api, &workspace_id)?.register_reviewer_child_session( + merge_request::RegisterReviewerChildSession { + workspace_id, + parent_runtime_id: source.runtime_id, + parent_worker_id: source.worker_id, + child_session_id: input.child_session_id, + reviewer_profile: "builtin:reviewer".into(), + now: Utc::now(), + }, + )?; + Ok(StatusCode::NO_CONTENT) + }) + .await } async fn scoped_register_merge_request_review_capability( @@ -16397,107 +17498,115 @@ async fn scoped_register_merge_request_review_capability( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult { - let workspace_id = parse_workspace_id(&workspace_id)?; - require_workspace_access(&workspace_id, &api)?; - let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; - let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get_by_id(&workspace_id, &merge_request_id)?; - let ticket_id = merge_request_ticket_id(&mr)?; - let requested_ticket_id = - resolve_workspace_ticket_reference(&api, &workspace_id, &input.ticket_id)?; - if requested_ticket_id != ticket_id { - return Err(Error::TicketAssignmentConflict( - "Reviewer handoff Ticket does not match the addressed Merge Request".into(), - ) - .into()); - } - let assignment = api - .store - .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into()) - })?; - if assignment.worker.runtime_id != source.runtime_id - || assignment.worker.worker_id != source.worker_id - { - return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not current assignee".into(), - ) - .into()); - } - let subject_ref = match mr.state { - merge_request::MergeRequestState::Merged => merged_result_source_ref(&mr)?, - merge_request::MergeRequestState::Open => { - let selector = mr - .selector_from - .as_deref() - .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; - let source_observation = observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - selector, - )?; - require_assigned_workdir_source( - &api, - &assignment, - &mr.repository_id, - selector, - &source_observation.resolved_ref, - )?; - source_observation.resolved_ref - } - merge_request::MergeRequestState::Closed => { - return Err( - Error::InvalidInput("closed Merge Request is not reviewable".into()).into(), - ); - } - }; - let mut ticket_merge_request_subjects = Vec::new(); - for linked in store.list_for_ticket(&workspace_id, &ticket_id)? { - let linked_subject_ref = if linked.state == merge_request::MergeRequestState::Merged { - merged_result_source_ref(&linked)? - } else { - let linked_selector = linked.selector_from.as_deref().ok_or_else(|| { - Error::InvalidInput(format!( - "Merge Request `{}` selector_from requires repair", - linked.merge_request_id - )) + api.host_executors + .host()? + .run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; + require_workspace_access(&workspace_id, &api)?; + let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; + let requested_ticket_id = + resolve_workspace_ticket_reference(&api, &workspace_id, &input.ticket_id)?; + if requested_ticket_id != ticket_id { + return Err(Error::TicketAssignmentConflict( + "Reviewer handoff Ticket does not match the addressed Merge Request".into(), + ) + .into()); + } + let assignment = api + .store + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict( + "Ticket has no active Coder work assignment".into(), + ) + })?; + if assignment.worker.runtime_id != source.runtime_id + || assignment.worker.worker_id != source.worker_id + { + return Err(Error::TicketAssignmentConflict( + "authenticated Worker is not current assignee".into(), + ) + .into()); + } + let subject_ref = match mr.state { + merge_request::MergeRequestState::Merged => merged_result_source_ref(&mr)?, + merge_request::MergeRequestState::Open => { + let selector = mr.selector_from.as_deref().ok_or_else(|| { + Error::InvalidInput("selector_from requires repair".into()) + })?; + let source_observation = observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + selector, + )?; + require_assigned_workdir_source( + &api, + &assignment, + &mr.repository_id, + selector, + &source_observation.resolved_ref, + )?; + source_observation.resolved_ref + } + merge_request::MergeRequestState::Closed => { + return Err(Error::InvalidInput( + "closed Merge Request is not reviewable".into(), + ) + .into()); + } + }; + let mut ticket_merge_request_subjects = Vec::new(); + for linked in store.list_for_ticket(&workspace_id, &ticket_id)? { + let linked_subject_ref = if linked.state == merge_request::MergeRequestState::Merged + { + merged_result_source_ref(&linked)? + } else { + let linked_selector = linked.selector_from.as_deref().ok_or_else(|| { + Error::InvalidInput(format!( + "Merge Request `{}` selector_from requires repair", + linked.merge_request_id + )) + })?; + observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &linked.repository_id, + linked_selector, + )? + .resolved_ref + }; + ticket_merge_request_subjects.push(merge_request::MergeRequestReviewSubject { + merge_request_id: linked.merge_request_id, + subject_ref: linked_subject_ref, + }); + } + let ticket_content_digest = api.authority.ticket(&ticket_id)?.content_digest; + store.request_review(merge_request::RequestMergeRequestReview { + merge_request_id, + ticket_id, + ticket_content_digest, + ticket_merge_request_subjects, + subject_ref, + child_session_id: input.child_session_id, + capability_token: input.capability_token, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), })?; - observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &linked.repository_id, - linked_selector, - )? - .resolved_ref - }; - ticket_merge_request_subjects.push(merge_request::MergeRequestReviewSubject { - merge_request_id: linked.merge_request_id, - subject_ref: linked_subject_ref, - }); - } - let ticket_content_digest = api.authority.ticket(&ticket_id)?.content_digest; - store.request_review(merge_request::RequestMergeRequestReview { - merge_request_id, - ticket_id, - ticket_content_digest, - ticket_merge_request_subjects, - subject_ref, - child_session_id: input.child_session_id, - capability_token: input.capability_token, - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: mr.repository_id, - runtime_id: source.runtime_id, - worker_id: source.worker_id, - assignment_id: assignment.assignment_id, - }, - now: Utc::now(), - })?; - Ok(StatusCode::NO_CONTENT) + Ok(StatusCode::NO_CONTENT) + }) + .await } async fn scoped_submit_merge_request_review( @@ -16505,67 +17614,74 @@ async fn scoped_submit_merge_request_review( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; - let store = merge_request_store(&api, &workspace_id)?; - let review_authorization = - store.authorize_review_submission(&merge_request_id, &input.capability_token)?; - if review_authorization.workspace_id != workspace_id - || review_authorization.merge_request_id != merge_request_id - { - return Err( - Error::MergeRequest(merge_request::MergeRequestError::Unauthorized( - "review grant invalid".into(), - )) - .into(), - ); - } - let ticket_id = review_authorization.ticket_id; - let mr = store.get_by_id(&workspace_id, &merge_request_id)?; - let assignment = api - .store - .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into()) - })?; - let current_subject_ref = match mr.state { - merge_request::MergeRequestState::Merged => merged_result_source_ref(&mr)?, - merge_request::MergeRequestState::Open => { - let selector = mr - .selector_from - .as_deref() - .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; - observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - selector, - )? - .resolved_ref - } - merge_request::MergeRequestState::Closed => { - return Err( - Error::InvalidInput("closed Merge Request is not reviewable".into()).into(), - ); - } - }; - let decision = internal_review_decision(input.decision); - let findings = input - .findings - .into_iter() - .map(internal_review_finding) - .collect(); - let event = store.submit_review(merge_request::SubmitMergeRequestReview { - merge_request_id, - ticket_id, - current_subject_ref, - capability_token: input.capability_token, - decision, - body: input.body, - findings, - now: Utc::now(), - })?; - Ok(Json(public_review_event(event))) + api.host_executors + .host()? + .run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; + let store = merge_request_store(&api, &workspace_id)?; + let review_authorization = + store.authorize_review_submission(&merge_request_id, &input.capability_token)?; + if review_authorization.workspace_id != workspace_id + || review_authorization.merge_request_id != merge_request_id + { + return Err( + Error::MergeRequest(merge_request::MergeRequestError::Unauthorized( + "review grant invalid".into(), + )) + .into(), + ); + } + let ticket_id = review_authorization.ticket_id; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let assignment = api + .store + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict( + "Ticket has no active Coder work assignment".into(), + ) + })?; + let current_subject_ref = match mr.state { + merge_request::MergeRequestState::Merged => merged_result_source_ref(&mr)?, + merge_request::MergeRequestState::Open => { + let selector = mr.selector_from.as_deref().ok_or_else(|| { + Error::InvalidInput("selector_from requires repair".into()) + })?; + observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + selector, + )? + .resolved_ref + } + merge_request::MergeRequestState::Closed => { + return Err(Error::InvalidInput( + "closed Merge Request is not reviewable".into(), + ) + .into()); + } + }; + let decision = internal_review_decision(input.decision); + let findings = input + .findings + .into_iter() + .map(internal_review_finding) + .collect(); + let event = store.submit_review(merge_request::SubmitMergeRequestReview { + merge_request_id, + ticket_id, + current_subject_ref, + capability_token: input.capability_token, + decision, + body: input.body, + findings, + now: Utc::now(), + })?; + Ok(Json(public_review_event(event))) + }) + .await } async fn scoped_revoke_merge_request_review( @@ -16574,44 +17690,51 @@ async fn scoped_revoke_merge_request_review( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; - require_workspace_access(&workspace_id, &api)?; - if !input.explicit_confirmation { - return Err(Error::BrowserReopenConfirmationRequired.into()); - } - let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; - let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get_by_id(&workspace_id, &merge_request_id)?; - let ticket_id = merge_request_ticket_id(&mr)?; - let assignment = api - .store - .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into()) - })?; - if assignment.worker.runtime_id != source.runtime_id - || assignment.worker.worker_id != source.worker_id - { - return Err(Error::TicketAssignmentConflict( - "authenticated Worker is not current assignee".into(), - ) - .into()); - } - let event = store.revoke_review(merge_request::RevokeMergeRequestReview { - merge_request_id, - ticket_id, - review_event_id: input.review_event_id, - reason: input.reason, - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: mr.repository_id, - runtime_id: source.runtime_id, - worker_id: source.worker_id, - assignment_id: assignment.assignment_id, - }, - now: Utc::now(), - })?; - Ok(Json(public_review_revoked_event(event))) + api.host_executors + .host()? + .run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; + require_workspace_access(&workspace_id, &api)?; + if !input.explicit_confirmation { + return Err(Error::BrowserReopenConfirmationRequired.into()); + } + let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; + let assignment = api + .store + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict( + "Ticket has no active Coder work assignment".into(), + ) + })?; + if assignment.worker.runtime_id != source.runtime_id + || assignment.worker.worker_id != source.worker_id + { + return Err(Error::TicketAssignmentConflict( + "authenticated Worker is not current assignee".into(), + ) + .into()); + } + let event = store.revoke_review(merge_request::RevokeMergeRequestReview { + merge_request_id, + ticket_id, + review_event_id: input.review_event_id, + reason: input.reason, + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id, + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), + })?; + Ok(Json(public_review_revoked_event(event))) + }) + .await } async fn scoped_complete_merge_request( @@ -16620,132 +17743,291 @@ async fn scoped_complete_merge_request( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json, ) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; - require_workspace_access(&workspace_id, &api)?; - let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; - require_online_workspace_orchestrator_source(&api, &source)?; - let store = merge_request_store(&api, &workspace_id)?; - let mr = store.get_by_id(&workspace_id, &merge_request_id)?; - let ticket_id = merge_request_ticket_id(&mr)?; - if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) { - let replay = merge_request::CompleteMergeRequest { - merge_request_id, - ticket_id, - operation_id: input.operation_id, - approval_event_id: input.approval_event_id, - current_subject_ref: existing.approved_source_ref.clone(), - target_ref_before: input.target_ref_before, - target_ref_after: input.target_ref_after, - strategy: internal_merge_strategy(input.strategy), - resolution: internal_conflict_resolution(input.resolution), - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: mr.repository_id.clone(), + api.host_executors + .host()? + .run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; + require_workspace_access(&workspace_id, &api)?; + let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; + require_online_workspace_orchestrator_source(&api, &source)?; + let store = merge_request_store(&api, &workspace_id)?; + let mr = store.get_by_id(&workspace_id, &merge_request_id)?; + let ticket_id = merge_request_ticket_id(&mr)?; + if let Some(existing) = recorded_merge_completion(&mr.thread, &input.operation_id) { + let replay = merge_request::CompleteMergeRequest { + merge_request_id, + ticket_id, + operation_id: input.operation_id, + approval_event_id: input.approval_event_id, + current_subject_ref: existing.approved_source_ref.clone(), + target_ref_before: input.target_ref_before, + target_ref_after: input.target_ref_after, + strategy: internal_merge_strategy(input.strategy), + resolution: internal_conflict_resolution(input.resolution), + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id.clone(), + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: String::new(), + }, + now: Utc::now(), + }; + return store + .complete(replay) + .map(public_merge_event) + .map(Json) + .map_err(Into::into); + } + let assignment = api + .store + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict( + "Ticket has no active Coder work assignment".into(), + ) + })?; + let selector = mr + .selector_from + .as_deref() + .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; + let current_source_ref = observe_published_source_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + selector, + )? + .resolved_ref; + let observed_target = observe_published_merge_ref( + &api, + &workspace_id, + &assignment.worker.runtime_id, + &mr.repository_id, + &mr.selector_to, + )?; + require_completed_target_observation( + &observed_target.resolved_ref, + &input.target_ref_before, + &input.target_ref_after, + )?; + let completion = merge_request::CompleteMergeRequest { + merge_request_id, + ticket_id, + operation_id: input.operation_id, + approval_event_id: input.approval_event_id, + current_subject_ref: current_source_ref, + target_ref_before: input.target_ref_before.clone(), + target_ref_after: input.target_ref_after.clone(), + strategy: internal_merge_strategy(input.strategy), + resolution: internal_conflict_resolution(input.resolution), + auth: merge_request::MergeRequestAuth { + workspace_id, + repository_id: mr.repository_id.clone(), + runtime_id: source.runtime_id, + worker_id: source.worker_id, + assignment_id: assignment.assignment_id, + }, + now: Utc::now(), + }; + store.validate_completion(&completion)?; + store + .complete(completion) + .map(public_merge_event) + .map(Json) + .map_err(Into::into) + }) + .await +} + +// Header-only entry point is retained solely for explicit legacy internal tests. +// Public dispatch must use the middleware-populated context below. +#[cfg(test)] +async fn scoped_ticket_case_update( + State(api): State, + headers: HeaderMap, + path: AxumPath<(String, String)>, + request: Json, +) -> ApiResult> { + let source = optional_worker_mutation_source(&api, &path.0.0, &headers)?; + apply_ticket_case_update(State(api), source, None, path, request).await +} + +async fn scoped_ticket_assignment_tracking_update_with_context( + State(api): State, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Json(update): Json, +) -> ApiResult> { + let context = context.clone(); + api.host_executors + .host()? + .run(move || { + let context = &context; + + validate_workspace_scope(&api, &workspace_id)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; + let source = + authenticated_ticket_worker_source(&headers, context)?.ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Tracking is self-reported by a verified Runtime Worker".into(), + ) + })?; + // Tracking authority is retained responsibility plus the registered singleton, + // NOT Ticket openness, active work, Worker state or a model-supplied role. + if !find_workspace_orchestrator(&api).is_some_and(|worker| worker.worker == source) { + return Err(Error::WorkspacePermissionDenied( + "Tracking updates require the registered current Workspace Orchestrator".into(), + ) + .into()); + } + let authentication_binding_id = context + .runtime_source + .as_ref() + .map(|source| source.authentication_binding_id.clone()) + .ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Tracking requires the Runtime binding actually used to authenticate the request" + .into(), + ) + })?; + let source_context = ticket::TicketAssignmentTrackingSourceContext { + actor: format!("worker:{}/{}", source.runtime_id, source.worker_id), runtime_id: source.runtime_id, worker_id: source.worker_id, - assignment_id: String::new(), - }, - now: Utc::now(), - }; - return store - .complete(replay) - .map(public_merge_event) - .map(Json) - .map_err(Into::into); + agent_key: "workspace-orchestrator".into(), + authentication_binding_id, + }; + // The Ticket-owned mutation checks current assignment/CAS and exact replay in + // one transaction. A historical receipt never restores operational grants. + let value = api.store.update_ticket_assignment_tracking( + &workspace_id, + &ticket_id, + &update, + &source_context, + )?; + Ok(Json(value)) + }) + .await +} + +async fn scoped_ticket_assignment_tracking_history( + State(api): State, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Query(query): Query, +) -> ApiResult> { + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; + let limit = query.limit.unwrap_or(50); + if !(1..=100).contains(&limit) { + return Err(Error::InvalidInput( + "tracking history limit must be between 1 and 100".into(), + ) + .into()); + } + let page = api.store.page_ticket_assignment_tracking_history( + &workspace_id, + &ticket_id, + &query.assignment_id, + query.after_event_id.as_deref(), + limit, + )?; + Ok(Json(server_api::TicketAssignmentTrackingHistoryPage { + items: page.items, + next_event_id: page.next_event_id, + })) + }) + .await +} + +async fn scoped_ticket_case_update_with_context( + State(api): State, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + path: AxumPath<(String, String)>, + request: Json, +) -> ApiResult> { + let source = authenticated_ticket_worker_source(&headers, context)?; + if source.is_none() && context.actor.is_none() { + return Err(Error::WorkspacePermissionDenied( + "Ticket case decisions require an authenticated actor".into(), + ) + .into()); } - let assignment = api - .store - .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into()) + apply_ticket_case_update(State(api), source, context.actor.as_ref(), path, request).await +} + +fn authenticated_ticket_worker_source( + headers: &HeaderMap, + context: &server_api::ServerRequestContext, +) -> Result> { + // Authentication already verified membership and the request's signature/body. + // Hints are redundant assertions, never authority or a required identity source. + let source = if let Some(source) = context.runtime_source.as_ref() { + let worker_id = source.worker_id.as_ref().ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Ticket case decisions require a verified Runtime Worker identity".into(), + ) })?; - let selector = mr - .selector_from - .as_deref() - .ok_or_else(|| Error::InvalidInput("selector_from requires repair".into()))?; - let current_source_ref = observe_published_source_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - selector, - )? - .resolved_ref; - let observed_target = observe_published_merge_ref( - &api, - &workspace_id, - &assignment.worker.runtime_id, - &mr.repository_id, - &mr.selector_to, - )?; - require_completed_target_observation( - &observed_target.resolved_ref, - &input.target_ref_before, - &input.target_ref_after, - )?; - let completion = merge_request::CompleteMergeRequest { - merge_request_id, - ticket_id, - operation_id: input.operation_id, - approval_event_id: input.approval_event_id, - current_subject_ref: current_source_ref, - target_ref_before: input.target_ref_before.clone(), - target_ref_after: input.target_ref_after.clone(), - strategy: internal_merge_strategy(input.strategy), - resolution: internal_conflict_resolution(input.resolution), - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: mr.repository_id.clone(), - runtime_id: source.runtime_id, - worker_id: source.worker_id, - assignment_id: assignment.assignment_id, - }, - now: Utc::now(), + Some(RuntimeWorkerRef::new(&source.runtime_id, worker_id)) + } else { + context + .worker_source + .as_ref() + .map(|source| RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id)) }; - store.validate_completion(&completion)?; - store - .complete(completion) - .map(public_merge_event) - .map(Json) - .map_err(Into::into) + if headers.contains_key("x-yoi-runtime-id") || headers.contains_key("x-yoi-worker-id") { + let matching = source.as_ref().is_some_and(|source| { + [ + ("x-yoi-runtime-id", &source.runtime_id), + ("x-yoi-worker-id", &source.worker_id), + ] + .into_iter() + .all(|(name, expected)| { + headers + .get(name) + .is_some_and(|value| value.as_bytes() == expected.as_bytes()) + }) + }); + if !matching { + return Err(Error::WorkspacePermissionDenied( + "Ticket case source hints do not match the verified Runtime Worker".into(), + ) + .into()); + } + } + Ok(source) } -async fn scoped_complete_ticket( +/// Backend replaces caller attribution with the authenticated Worker or Account/User pair. +/// Optional identity hints must match verified context; Runtime-only identity cannot become +/// a human judgment. Ticket owns item-digest/state/explicit nullable closure-key CAS (including +/// reopen ABA detection), exact full-input/source replay and immutable decision history. +/// Close commits identity-specific work release with the judgment/receipt; failure rolls back +/// the whole transaction. It preserves display responsibility, not execution grants. Reopen +/// restores no old claim, and no case action starts/stops Workers or approves/integrates MRs. +async fn apply_ticket_case_update( State(api): State, - headers: HeaderMap, + source: Option, + actor: Option<&RequestActor>, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, + Json(mut request): Json, ) -> ApiResult> { - scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, ticket_id)), - Json(ticket::TicketStateUpdate { - operation_key: input.operation_key, - expected_content_digest: input.expected_content_digest, - expected_state: input.expected_state, - state: TicketWorkflowState::Done, - reason: input.reason, - references: input.references, - author: None, - }), - ) - .await -} + let actor = actor.cloned(); + api.host_executors.host()?.run(move || { + let actor = actor.as_ref(); -async fn scoped_ticket_state_update( - State(api): State, - headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(mut request): Json, -) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; let mut backend = browser_ticket_backend(&api)?; let before = backend .show(TicketIdOrSlug::Id(ticket_id.clone())) .map_err(Error::from)?; - if let Some(source) = optional_worker_mutation_source(&api, &workspace_id, &headers)? { + if let Some(source) = source { let author = format!("worker:{}/{}", source.runtime_id, source.worker_id); let replay_event = before.events.iter().find(|event| { event.author.as_deref() == Some(author.as_str()) @@ -16773,16 +18055,30 @@ async fn scoped_ticket_state_update( .map(|(key, value)| (key.clone(), value.clone())) .collect() } else { - context.attributes("state_update") + context.attributes("case_update") }; backend = backend.with_event_attributes(attributes); + } else if let Some(actor) = actor { + // Include both verified IDs in the author (and thus the receipt fingerprint). + // JSON tuple encoding avoids ambiguous identity concatenation; proof tokens, + // auth method and signature timestamps are not replay identity. + let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id)) + .map_err(|error| Error::Config(format!("failed to encode case actor: {error}")))?; + request.author = Some(format!("workspace-user:{identity}")); + backend = backend.with_event_attributes(BTreeMap::from([ + ("source_account_id".into(), actor.account_id.clone()), + ("source_user_id".into(), actor.user_id.clone()), + ])); } else { + // Only the test-only header adapter can reach this legacy attribution. request.author = Some("workspace-user".into()); } let ticket = backend - .update_state(&ticket_id, request) + .update_case(&ticket_id, request) .map_err(Error::from)?; Ok(Json(ticket)) + + }).await } fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { @@ -16792,30 +18088,6 @@ fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { Ok(()) } -async fn scoped_close_ticket_record( - State(api): State, - AxumPath((workspace_id, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult { - let _ = scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, id)), - Json(ticket::TicketStateUpdate { - state: TicketWorkflowState::Closed, - operation_key: request.operation_key, - expected_content_digest: request.expected_content_digest, - expected_state: request.expected_state, - reason: request.reason, - references: request.references, - author: request.author, - }), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - async fn scoped_record_ticket_relation( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, @@ -16973,11 +18245,9 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI | TicketBackendOperation::AddEvent { id, .. } | TicketBackendOperation::AddStateChanged { id, .. } | TicketBackendOperation::AddIntakeSummary { id, .. } - | TicketBackendOperation::SetStateField { id, .. } - | TicketBackendOperation::SetWorkflowState { id, .. } | TicketBackendOperation::MarkReady { id, .. } | TicketBackendOperation::QueueReady { id, .. } - | TicketBackendOperation::Close { id, .. } + | TicketBackendOperation::SetTicketTags { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. } | TicketBackendOperation::RemoveTicketRelation { id, .. } | TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id), @@ -17012,9 +18282,7 @@ fn bind_worker_ticket_operation_source( TicketBackendOperation::Create { input } => input.author = Some(author), TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author), TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author), - TicketBackendOperation::AddStateChanged { change, .. } - | TicketBackendOperation::SetStateField { change, .. } - | TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author), + TicketBackendOperation::AddStateChanged { change, .. } => change.author = Some(author), TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author), TicketBackendOperation::MarkReady { request, .. } => { request.author = Some(author.clone()); @@ -17039,12 +18307,12 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati TicketBackendOperation::EditItem { .. } => "edit_item", TicketBackendOperation::AddEvent { .. } => "add_event", TicketBackendOperation::AddStateChanged { .. } => "add_state_changed", + TicketBackendOperation::UpdateCase { .. } => "case_update", TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary", - TicketBackendOperation::SetStateField { .. } => "set_state_field", - TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state", TicketBackendOperation::MarkReady { .. } => "mark_ready", TicketBackendOperation::QueueReady { .. } => "queue_ready", - TicketBackendOperation::Close { .. } => "close", + TicketBackendOperation::MutateWorkspaceTag { .. } => "mutate_workspace_tag", + TicketBackendOperation::SetTicketTags { .. } => "set_ticket_tags", TicketBackendOperation::AddTicketRelation { .. } => "add_relation", TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation", TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record", @@ -17058,8 +18326,8 @@ fn ticket_operation_initial_state(operation: &TicketBackendOperation) -> String .workflow_state .as_ref() .map(|state| state.as_str().to_string()) - .unwrap_or_else(|| TicketWorkflowState::Planning.as_str().to_string()), - _ => TicketWorkflowState::Planning.as_str().to_string(), + .unwrap_or_else(|| TicketState::Open.as_str().to_string()), + _ => TicketState::Open.as_str().to_string(), } } @@ -17132,18 +18400,6 @@ fn active_orchestrator_assignment( } } -fn orchestrator_interested( - api: &WorkspaceApi, - workspace_id: &str, - ticket_id: &str, - state: &str, -) -> Result> { - if !matches!(state, "queued" | "inprogress") { - return Ok(None); - } - active_orchestrator_assignment(api, workspace_id, ticket_id) -} - fn worker_ticket_source_context( api: &WorkspaceApi, workspace_id: &str, @@ -17302,13 +18558,8 @@ fn notify_ticket_recipients( { recipients.push(assignment.worker.clone()); } - if orchestrator_interested(api, workspace_id, ticket_id, current_state) - .ok() - .flatten() - .is_some() - && let Some(orchestrator) = find_workspace_orchestrator(api) - { - recipients.push(orchestrator.worker.clone()); + if let Some(orchestrator) = find_workspace_orchestrator(api) { + dispatch_orchestrator_assignment_attention(api, &orchestrator.worker); } recipients.sort(); recipients.dedup(); @@ -17368,6 +18619,20 @@ fn authenticate_worker_mutation_source( Ok(worker) } +async fn authenticate_worker_mutation_source_owned( + api: &WorkspaceApi, + workspace_id: &str, + headers: &HeaderMap, +) -> Result { + let api = api.clone(); + let workspace_id = workspace_id.to_owned(); + let headers = headers.clone(); + api.host_executors + .host()? + .run(move || authenticate_worker_mutation_source(&api, &workspace_id, &headers)) + .await +} + fn current_worker_identity( api: &WorkspaceApi, workspace_id: &str, @@ -17395,44 +18660,93 @@ fn runtime_local_owner_worker_id<'a>( (caller.runtime_id == target_runtime_id).then_some(caller.worker_id.as_str()) } +// Locked async workflows stay on Tokio. Each accepted synchronous mutation +// retains this same owned guard through execution and discarded-result cleanup. +async fn run_worker_workdir_db_stage_locked( + api: &WorkspaceApi, + session_guard: &Arc>, + operation: impl FnOnce(WorkspaceApi) -> std::result::Result + Send + 'static, +) -> std::result::Result +where + T: Send + 'static, + E: From + Send + 'static, +{ + let stage_api = api.clone(); + let stage_guard = session_guard.clone(); + let (outcome, _session_guard) = api + .store + .db_executor() + .run(move || { + let outcome = operation(stage_api); + // Keep the guard with the outcome, including errors. If the waiter was + // cancelled, the job drops the outcome before releasing this guard. + Ok::<_, E>((outcome, stage_guard)) + }) + .await?; + outcome +} + async fn open_current_worker_workdir_session_locked( api: &WorkspaceApi, worker: &RuntimeWorkerRef, link: &WorkerWorkdirLinkRecord, + session_guard: &Arc>, ) -> Result { - let workdir = api + let stage_api = api.clone(); + let stage_worker = worker.clone(); + let stage_link = link.clone(); + let (workdir, effective_capabilities) = api .store - .list_workdir_registry(&api.config.workspace_id, 10_000)? - .into_iter() - .find(|workdir| workdir.workdir_id == link.workdir_id) - .ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: worker.runtime_id.clone(), - code: "working_directory_not_found".to_string(), - message: format!( - "attached Workdir {} is not registered in this Workspace", - link.workdir_id - ), - })?; - if matches!( - workdir.source, - WorkdirRegistrySource::WorkspaceConfig { .. } - ) { - return Err(Error::WorkspacePermissionDenied( - "Logical config has no filesystem session".into(), - )); - } - let effective_capabilities = - effective_worker_workdir_capabilities(api, worker, &workdir, link.capabilities)?; + .db_executor() + .run(move || { + let workdir = stage_api + .store + .list_workdir_registry(&stage_api.config.workspace_id, 10_000)? + .into_iter() + .find(|workdir| workdir.workdir_id == stage_link.workdir_id) + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: stage_worker.runtime_id.clone(), + code: "working_directory_not_found".to_string(), + message: format!( + "attached Workdir {} is not registered in this Workspace", + stage_link.workdir_id + ), + })?; + if matches!( + workdir.source, + WorkdirRegistrySource::WorkspaceConfig { .. } + ) { + return Err(Error::WorkspacePermissionDenied( + "Logical config has no filesystem session".into(), + )); + } + let capabilities = effective_worker_workdir_capabilities( + &stage_api, + &stage_worker, + &workdir, + stage_link.capabilities, + )?; + Ok((workdir, capabilities)) + }) + .await?; if let WorkdirRegistrySource::ExternalGrant { grant_id } = &workdir.source { close_current_worker_attachment_session_locked(api, worker).await?; + let stage_api = api.clone(); + let stage_grant_id = grant_id.clone(); let mut grant = api .store - .get_external_workdir_grant(&api.config.workspace_id, grant_id)? - .ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "external_workdir_grant_not_found".to_string(), - message: "External Workdir grant is unavailable".to_string(), - })?; + .db_executor() + .run(move || { + stage_api + .store + .get_external_workdir_grant(&stage_api.config.workspace_id, &stage_grant_id)? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "external_workdir_grant_not_found".to_string(), + message: "External Workdir grant is unavailable".to_string(), + }) + }) + .await?; let expired = external_workdir_grant_is_expired(&grant)?; if expired { grant = fence_external_workdir_grant_expired(api, grant_id) @@ -17440,9 +18754,13 @@ async fn open_current_worker_workdir_session_locked( .map_err(|error| error.error)?; } if matches!(grant.status.as_str(), "revoked" | "expired") { - cleanup_external_workdir_attachments_with_lock(api, &grant.workdir_id, Some(worker)) - .await - .map_err(|error| error.error)?; + cleanup_external_workdir_attachments_with_lock( + api, + &grant.workdir_id, + Some((worker, session_guard)), + ) + .await + .map_err(|error| error.error)?; cancel_external_workdir_expiry(api, grant_id); } if grant.status != "online" { @@ -17509,31 +18827,63 @@ async fn open_current_worker_workdir_session_locked( }); } }; + let stage_api = api.clone(); + let stage_repository_id = repository_id.to_string(); let repository = api - .require_configured_workspace_repository(repository_id) - .map_err(|error| error.error)?; + .store + .db_executor() + .run(move || { + stage_api + .require_configured_workspace_repository(&stage_repository_id) + .map_err(|error| error.error) + }) + .await?; let repository_requires_access = repository.source.kind == server_api::RepositorySourceKind::Ssh; if repository_requires_access { close_current_worker_attachment_session_locked(api, worker).await?; - let access = repository_access_request_for_workdir( - api, - runtime_id, - &workdir.workdir_id, - &format!( - "workdir-session:{}:{}:{}", - worker.runtime_id, worker.worker_id, workdir.workdir_id - ), - ) - .map_err(|error| error.error)? - .ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: runtime_id.to_string(), - code: "working_directory_remote_repository_access_required".to_string(), - message: "current Repository SSH access authority is unavailable".to_string(), - })?; - api.runtime - .authorize_working_directory_repository_access(runtime_id, access) - .map_err(RuntimeRegistryError::into_error)?; + let stage_api = api.clone(); + let stage_runtime_id = runtime_id.to_string(); + let stage_workdir_id = workdir.workdir_id.clone(); + let operation_id = format!( + "workdir-session:{}:{}:{}", + worker.runtime_id, worker.worker_id, workdir.workdir_id + ); + let stage_guard = session_guard.clone(); + let access = api + .store + .db_executor() + .run(move || { + let _session_guard = stage_guard; + repository_access_request_for_workdir( + &stage_api, + &stage_runtime_id, + &stage_workdir_id, + &operation_id, + ) + .map_err(|error| error.error)? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: stage_runtime_id, + code: "working_directory_remote_repository_access_required".to_string(), + message: "current Repository SSH access authority is unavailable".to_string(), + }) + }) + .await?; + let stage_api = api.clone(); + let stage_runtime_id = runtime_id.to_string(); + // The accepted Runtime authorization must retain the actual session lock, + // not just the async waiter that submitted it. + let stage_guard = session_guard.clone(); + api.host_executors + .host()? + .run(move || { + let _session_guard = stage_guard; + stage_api + .runtime + .authorize_working_directory_repository_access(&stage_runtime_id, access) + .map_err(RuntimeRegistryError::into_error) + }) + .await?; } else { // The registry is an operation-session cache, not routing authority. // Close any prior alias session before opening the explicitly selected @@ -17662,10 +19012,10 @@ fn restore_logical_workdir_attachments( .map_err(ApiError::from) } -fn sync_runtime_worker_workdir_attachments( +fn prepare_runtime_worker_workdir_attachments( api: &WorkspaceApi, worker: &RuntimeWorkerRef, -) -> ApiResult<()> { +) -> ApiResult> { let attachments = api .store .list_worker_workdir_links(&api.config.workspace_id, worker)? @@ -17708,6 +19058,14 @@ fn sync_runtime_worker_workdir_attachments( }) }) .collect::>>()?; + Ok(attachments) +} + +fn replace_runtime_worker_workdir_attachments( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + attachments: Vec, +) -> ApiResult<()> { let result = api .runtime .replace_worker_workdir_attachments(worker, attachments) @@ -17727,12 +19085,68 @@ fn sync_runtime_worker_workdir_attachments( Ok(()) } +#[cfg(test)] +fn sync_runtime_worker_workdir_attachments( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> ApiResult<()> { + let attachments = prepare_runtime_worker_workdir_attachments(api, worker)?; + replace_runtime_worker_workdir_attachments(api, worker, attachments) +} + +async fn sync_runtime_worker_workdir_attachments_locked( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + session_guard: &Arc>, +) -> ApiResult<()> { + let stage_api = api.clone(); + let stage_worker = worker.clone(); + let stage_guard = session_guard.clone(); + let attachments = api + .store + .db_executor() + .run(move || { + let _session_guard = stage_guard; + prepare_runtime_worker_workdir_attachments(&stage_api, &stage_worker) + }) + .await?; + let stage_api = api.clone(); + let stage_worker = worker.clone(); + let stage_guard = session_guard.clone(); + api.host_executors + .host()? + .run(move || { + let _session_guard = stage_guard; + replace_runtime_worker_workdir_attachments(&stage_api, &stage_worker, attachments) + }) + .await +} + async fn refresh_current_worker_session_locked( api: &WorkspaceApi, worker: &RuntimeWorkerRef, + session_guard: &Arc>, ) -> Result<()> { close_current_worker_attachment_session_locked(api, worker).await?; + let stage_api = api.clone(); + let stage_worker = worker.clone(); let remaining = api + .store + .db_executor() + .run(move || active_filesystem_workdir_links(&stage_api, &stage_worker)) + .await?; + if remaining.len() == 1 { + open_current_worker_workdir_session_locked(api, worker, &remaining[0], session_guard) + .await?; + } + Ok(()) +} + +fn active_filesystem_workdir_links( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> Result> { + Ok(api .store .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() @@ -17746,11 +19160,7 @@ async fn refresh_current_worker_session_locked( !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) }) }) - .collect::>(); - if remaining.len() == 1 { - open_current_worker_workdir_session_locked(api, worker, &remaining[0]).await?; - } - Ok(()) + .collect()) } #[derive(Debug, Deserialize)] @@ -17765,35 +19175,107 @@ async fn scoped_submit_backend_job_result( headers: HeaderMap, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let worker = current_worker_identity(&api, &path.workspace_id, &headers)?; - let binding = api - .store - .worker_registry_projection(&api.config.workspace_id, &worker)? - .and_then(|projection| projection.job) - .ok_or_else(|| { - Error::InvalidInput( - "current Worker is not bound to an active Backend Job attempt".to_string(), - ) - })?; - let attempt = api - .store - .get_backend_job_attempt( + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let worker = current_worker_identity(&api, &path.workspace_id, &headers)?; + let binding = api + .store + .worker_registry_projection(&api.config.workspace_id, &worker)? + .and_then(|projection| projection.job) + .ok_or_else(|| { + Error::InvalidInput( + "current Worker is not bound to an active Backend Job attempt".to_string(), + ) + })?; + let attempt = api + .store + .get_backend_job_attempt( + &api.config.workspace_id, + &binding.job_id, + &binding.attempt_id, + )? + .ok_or_else(|| { + Error::Store("Backend Job attempt binding disappeared".to_string()) + })?; + let acceptance = api.accept_backend_job_result( + &worker, + &BackendJobResultSubmission { + job_id: binding.job_id, + attempt_id: binding.attempt_id, + input_digest: attempt.input_digest, + result: request.result, + }, + )?; + Ok(Json(acceptance)) + }) + .await +} + +async fn detach_worker_workdir_stage_locked( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + workdir_id: &str, + session_guard: &Arc>, +) -> Result> { + let worker = worker.clone(); + let workdir_id = workdir_id.to_string(); + run_worker_workdir_db_stage_locked(api, session_guard, move |api| { + api.store.detach_worker_workdir( &api.config.workspace_id, - &binding.job_id, - &binding.attempt_id, - )? - .ok_or_else(|| Error::Store("Backend Job attempt binding disappeared".to_string()))?; - let acceptance = api.accept_backend_job_result( - &worker, - &BackendJobResultSubmission { - job_id: binding.job_id, - attempt_id: binding.attempt_id, - input_digest: attempt.input_digest, - result: request.result, - }, - )?; - Ok(Json(acceptance)) + &worker, + Some(&workdir_id), + &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + ) + }) + .await +} + +async fn import_current_worker_workdir_observation( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + session_guard: Option<&Arc>>, +) -> ApiResult<()> { + let stage_api = api.clone(); + let stage_worker = worker.clone(); + let has_history = api + .store + .db_executor() + .run(move || { + stage_api + .store + .worker_workdir_link_history_exists(&stage_api.config.workspace_id, &stage_worker) + }) + .await?; + if !has_history { + let stage_api = api.clone(); + let stage_worker = worker.clone(); + let stage_guard = session_guard.cloned(); + let observed_worker = api + .host_executors + .host()? + .run(move || { + let _session_guard = stage_guard; + stage_api + .runtime + .worker(&stage_worker) + .map_err(RuntimeRegistryError::into_error) + }) + .await?; + if !observed_worker.workdir_attachments.is_empty() { + let stage_api = api.clone(); + let stage_guard = session_guard.cloned(); + api.store + .db_executor() + .run(move || { + let _session_guard = stage_guard; + sync_worker_observation(&stage_api, &observed_worker) + }) + .await?; + } + } + Ok(()) } async fn scoped_attach_current_worker_workdir( @@ -17802,115 +19284,141 @@ async fn scoped_attach_current_worker_workdir( headers: HeaderMap, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let worker = current_worker_identity(&api, &path.workspace_id, &headers)?; + let stage_api = api.clone(); + let worker = api + .host_executors + .host()? + .run(move || -> ApiResult<_> { + validate_workspace_scope(&stage_api, &path.workspace_id)?; + Ok(current_worker_identity( + &stage_api, + &path.workspace_id, + &headers, + )?) + }) + .await?; let alias = workdir::WorkdirAttachmentAlias::new(request.alias) .map_err(|error| Error::InvalidInput(error.to_string()))?; let workdir_id = request.working_directory_id.trim(); if workdir_id.is_empty() || workdir_id.chars().any(char::is_control) { return Err(Error::InvalidRecordId(request.working_directory_id).into()); } - let workdir = api + let stage_api = api.clone(); + let stage_worker = worker.clone(); + let stage_workdir_id = workdir_id.to_string(); + let capabilities = api .store - .list_workdir_registry(&api.config.workspace_id, 10_000)? - .into_iter() - .find(|workdir| workdir.workdir_id == workdir_id) - .ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: worker.runtime_id.clone(), - code: "working_directory_not_found".to_string(), - message: format!("unknown Workdir `{workdir_id}`"), - })?; - // A failed removal must not prevent its owner from attaching a still usable - // repository checkout to resolve current blockers. Runtime binding validates - // the actual checkout; durable pending-removal reservations still fence this - // mutation. Other sources and missing/corrupted materializations stay denied. - let repository_retry = workdir.materialization_status == "pending" - && matches!(workdir.source, WorkdirRegistrySource::Repository { .. }); - if workdir.materialization_status != "present" && !repository_retry { - return Err(Error::WorkdirAttachmentConflict(format!( - "Workdir `{workdir_id}` is not available for attachment ({})", - workdir.materialization_status - )) - .into()); - } - match &workdir.source { - WorkdirRegistrySource::Repository { .. } => {} - WorkdirRegistrySource::WorkspaceConfig { .. } => { - return Err(Error::WorkspacePermissionDenied( - "Use the grant-bound Workspace config attachment operation".into(), - ) - .into()); - } - WorkdirRegistrySource::ExternalGrant { grant_id } => { - let grant = api + .db_executor() + .run(move || -> ApiResult<_> { + let api = stage_api; + let worker = stage_worker; + let workdir_id = stage_workdir_id.as_str(); + let workdir = api .store - .get_external_workdir_grant(&api.config.workspace_id, grant_id)? + .list_workdir_registry(&api.config.workspace_id, 10_000)? + .into_iter() + .find(|workdir| workdir.workdir_id == workdir_id) .ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: "external-workdir-provider".to_string(), - code: "external_workdir_unavailable".to_string(), - message: "External Workdir grant is unavailable".to_string(), + runtime_id: worker.runtime_id.clone(), + code: "working_directory_not_found".to_string(), + message: format!("unknown Workdir `{workdir_id}`"), })?; - let grant_expired = external_workdir_grant_is_expired(&grant)?; - let online = api - .external_workdir_providers - .lock() - .expect("External Workdir provider registry poisoned") - .connection(grant_id) - .is_some_and(|connection| { - connection.generation == grant.generation - && connection.provider_instance_id == grant.provider_instance_id - && connection.workdir_id == workdir.workdir_id - && connection - .expires_at - .is_none_or(|expires_at| expires_at > Utc::now()) - }); - if grant.status != "online" || grant_expired || !online { - return Err(Error::RuntimeOperationFailed { - runtime_id: "external-workdir-provider".to_string(), - code: "external_workdir_unavailable".to_string(), - message: "External Workdir provider is offline or expired".to_string(), - } + // A failed removal must not prevent its owner from attaching a still usable + // repository checkout to resolve current blockers. Runtime binding validates + // the actual checkout; durable pending-removal reservations still fence this + // mutation. Other sources and missing/corrupted materializations stay denied. + let repository_retry = workdir.materialization_status == "pending" + && matches!(workdir.source, WorkdirRegistrySource::Repository { .. }); + if workdir.materialization_status != "present" && !repository_retry { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir `{workdir_id}` is not available for attachment ({})", + workdir.materialization_status + )) .into()); } - } - } - let capabilities = ticket_target_capabilities_for_workdir(&api, &worker, &workdir)?; + match &workdir.source { + WorkdirRegistrySource::Repository { .. } => {} + WorkdirRegistrySource::WorkspaceConfig { .. } => { + return Err(Error::WorkspacePermissionDenied( + "Use the grant-bound Workspace config attachment operation".into(), + ) + .into()); + } + WorkdirRegistrySource::ExternalGrant { grant_id } => { + let grant = api + .store + .get_external_workdir_grant(&api.config.workspace_id, grant_id)? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "external-workdir-provider".to_string(), + code: "external_workdir_unavailable".to_string(), + message: "External Workdir grant is unavailable".to_string(), + })?; + let grant_expired = external_workdir_grant_is_expired(&grant)?; + let online = api + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .connection(grant_id) + .is_some_and(|connection| { + connection.generation == grant.generation + && connection.provider_instance_id == grant.provider_instance_id + && connection.workdir_id == workdir.workdir_id + && connection + .expires_at + .is_none_or(|expires_at| expires_at > Utc::now()) + }); + if grant.status != "online" || grant_expired || !online { + return Err(Error::RuntimeOperationFailed { + runtime_id: "external-workdir-provider".to_string(), + code: "external_workdir_unavailable".to_string(), + message: "External Workdir provider is offline or expired".to_string(), + } + .into()); + } + } + } + Ok(ticket_target_capabilities_for_workdir( + &api, &worker, &workdir, + )?) + }) + .await?; let session_lock = current_worker_session_lock(&api, &worker); - let _session_guard = session_lock.lock().await; - if !api - .store - .worker_workdir_link_history_exists(&api.config.workspace_id, &worker)? - { - let observed_worker = api - .runtime - .worker(&worker) - .map_err(|error| error.into_error())?; - if !observed_worker.workdir_attachments.is_empty() { - sync_worker_observation(&api, &observed_worker)?; - } - } - if let Some(existing) = api - .store - .list_worker_workdir_links(&api.config.workspace_id, &worker)? - .into_iter() - .find(|link| link.unlinked_at.is_none() && link.alias == alias.as_str()) - { - if existing.workdir_id != workdir_id { - return Err(Error::WorkdirAttachmentConflict(format!( - "Worker {}:{} attachment alias `{alias}` is already bound to Workdir {}", - worker.runtime_id, worker.worker_id, existing.workdir_id - )) - .into()); - } - let effective_capabilities = existing.capabilities.intersection(capabilities); - if effective_capabilities != existing.capabilities { - let mut attenuated = existing.clone(); - attenuated.capabilities = effective_capabilities; - api.store.attach_worker_workdir(&attenuated)?; - } - sync_runtime_worker_workdir_attachments(&api, &worker)?; + let _session_guard = Arc::new(session_lock.lock_owned().await); + import_current_worker_workdir_observation(&api, &worker, Some(&_session_guard)).await?; + let stage_worker = worker.clone(); + let stage_alias = alias.to_string(); + let stage_workdir_id = workdir_id.to_string(); + let existing = + run_worker_workdir_db_stage_locked(&api, &_session_guard, move |api| -> ApiResult<_> { + let existing = api + .store + .list_worker_workdir_links(&api.config.workspace_id, &stage_worker)? + .into_iter() + .find(|link| link.unlinked_at.is_none() && link.alias == stage_alias); + if let Some(existing) = existing { + if existing.workdir_id != stage_workdir_id { + return Err(Error::WorkdirAttachmentConflict(format!( + "Worker {}:{} attachment alias `{stage_alias}` is already bound to Workdir {}", + stage_worker.runtime_id, stage_worker.worker_id, existing.workdir_id + )).into()); + } + let effective_capabilities = existing.capabilities.intersection(capabilities); + if effective_capabilities != existing.capabilities { + let mut attenuated = existing; + attenuated.capabilities = effective_capabilities; + api.store.attach_worker_workdir(&attenuated)?; + } + Ok(Some(effective_capabilities)) + } else { + Ok(None) + } + }) + .await?; + if let Some(effective_capabilities) = existing { + sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await?; api.worker_projection - .refresh(&worker) + .refresh_async(&worker) + .await .map_err(ApiError::from)?; return Ok(Json(server_api::CurrentWorkerWorkdirAttachmentResponse { workspace_id: api.config.workspace_id.clone(), @@ -17921,7 +19429,7 @@ async fn scoped_attach_current_worker_workdir( })); } close_current_worker_attachment_session_locked(&api, &worker).await?; - let link = api.store.attach_worker_workdir(&WorkerWorkdirLinkRecord { + let new_link = WorkerWorkdirLinkRecord { connection_id: String::new(), workspace_id: api.config.workspace_id.clone(), worker: worker.clone(), @@ -17930,56 +19438,46 @@ async fn scoped_attach_current_worker_workdir( capabilities, linked_at: Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), unlinked_at: None, - })?; - if let Err(error) = sync_runtime_worker_workdir_attachments(&api, &worker) { - let _ = api.store.detach_worker_workdir( - &api.config.workspace_id, - &worker, - Some(workdir_id), - &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - ); + }; + let link = run_worker_workdir_db_stage_locked(&api, &_session_guard, move |api| { + api.store.attach_worker_workdir(&new_link) + }) + .await?; + if let Err(error) = + sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await + { + let _ = + detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard).await; return Err(error); } + let stage_api = api.clone(); + let stage_worker = worker.clone(); let active_count = api .store - .list_worker_workdir_links(&api.config.workspace_id, &worker)? - .into_iter() - .filter(|link| link.unlinked_at.is_none()) - .filter(|link| { - api.store - .get_workdir_registry(&api.config.workspace_id, &link.workdir_id) - .ok() - .flatten() - .is_none_or(|record| { - !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) - }) + .db_executor() + .run(move || { + active_filesystem_workdir_links(&stage_api, &stage_worker).map(|links| links.len()) }) - .count(); + .await?; if active_count == 1 - && let Err(error) = open_current_worker_workdir_session_locked(&api, &worker, &link).await + && let Err(error) = + open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await { - let _ = api.store.detach_worker_workdir( - &api.config.workspace_id, - &worker, - Some(workdir_id), - &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - ); - let _ = sync_runtime_worker_workdir_attachments(&api, &worker); + let _ = + detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard).await; + let _ = + sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await; return Err(error.into()); } if let Err(error) = api .worker_projection - .refresh(&worker) + .refresh_async(&worker) + .await .map_err(ApiError::from) { - api.store.detach_worker_workdir( - &api.config.workspace_id, - &worker, - Some(workdir_id), - &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - )?; - sync_runtime_worker_workdir_attachments(&api, &worker)?; - refresh_current_worker_session_locked(&api, &worker).await?; + detach_worker_workdir_stage_locked(&api, &worker, workdir_id, &_session_guard).await?; + sync_runtime_worker_workdir_attachments_locked(&api, &worker, &_session_guard).await?; + refresh_current_worker_session_locked(&api, &worker, &_session_guard).await?; return Err(error); } Ok(Json(server_api::CurrentWorkerWorkdirAttachmentResponse { @@ -18109,7 +19607,12 @@ async fn scoped_detach_current_worker_workdir( validate_workspace_scope(&api, &workspace_id)?; let alias = workdir::WorkdirAttachmentAlias::new(alias) .map_err(|error| Error::InvalidInput(error.to_string()))?; - let worker = current_worker_identity(&api, &workspace_id, &headers)?; + let stage_api = api.clone(); + let worker = api + .host_executors + .host()? + .run(move || current_worker_identity(&stage_api, &workspace_id, &headers)) + .await?; detach_current_worker_workdir( &api, &worker, @@ -18120,6 +19623,18 @@ async fn scoped_detach_current_worker_workdir( .map(Json) } +async fn restore_worker_workdir_connection_stage_locked( + api: &WorkspaceApi, + link: &WorkerWorkdirLinkRecord, + session_guard: &Arc>, +) -> Result<()> { + let link = link.clone(); + run_worker_workdir_db_stage_locked(api, session_guard, move |api| { + api.store.restore_worker_workdir_connection(&link) + }) + .await +} + async fn detach_current_worker_workdir( api: &WorkspaceApi, worker: &RuntimeWorkerRef, @@ -18127,70 +19642,84 @@ async fn detach_current_worker_workdir( expected_connection_id: Option<&str>, ) -> ApiResult { let session_lock = current_worker_session_lock(api, worker); - let _session_guard = session_lock.lock().await; + let _session_guard = Arc::new(session_lock.lock_owned().await); + let stage_api = api.clone(); + let stage_worker = worker.clone(); + let stage_alias = alias.to_string(); + let expected_connection_id = expected_connection_id.map(str::to_string); let link = api .store - .list_worker_workdir_links(&api.config.workspace_id, worker)? - .into_iter() - .find(|link| link.unlinked_at.is_none() && link.alias == alias) - .ok_or_else(|| { - Error::WorkdirAttachmentConflict(format!( - "Worker {}:{} has no Workdir attachment `{alias}`", - worker.runtime_id, worker.worker_id - )) - })?; - if expected_connection_id.is_some_and(|expected| expected != link.connection_id) { - return Err(Error::WorkdirAttachmentConflict(format!( - "Workdir attachment `{alias}` connection changed during detach" - )) - .into()); - } + .db_executor() + .run(move || -> ApiResult<_> { + let link = stage_api + .store + .list_worker_workdir_links(&stage_api.config.workspace_id, &stage_worker)? + .into_iter() + .find(|link| link.unlinked_at.is_none() && link.alias == stage_alias) + .ok_or_else(|| { + Error::WorkdirAttachmentConflict(format!( + "Worker {}:{} has no Workdir attachment `{stage_alias}`", + stage_worker.runtime_id, stage_worker.worker_id + )) + })?; + if expected_connection_id + .as_ref() + .is_some_and(|expected| *expected != link.connection_id) + { + return Err(Error::WorkdirAttachmentConflict(format!( + "Workdir attachment `{stage_alias}` connection changed during detach" + )) + .into()); + } + Ok(link) + }) + .await?; close_current_worker_attachment_session_locked(api, worker).await?; close_current_worker_command_sessions_for_alias_locked(api, worker, alias).await?; // Even unconditional HTTP requests condition the mutation on the lifetime just read // under the session lock, so a concurrent ledger replacement cannot be removed. - api.store.detach_worker_workdir_connection( - &api.config.workspace_id, - worker, - alias, - &link.connection_id, - &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), - )?; - if let Err(error) = sync_runtime_worker_workdir_attachments(api, worker) { - api.store.restore_worker_workdir_connection(&link)?; + let stage_link = link.clone(); + run_worker_workdir_db_stage_locked(api, &_session_guard, move |api| { + api.store.detach_worker_workdir_connection( + &api.config.workspace_id, + &stage_link.worker, + &stage_link.alias, + &stage_link.connection_id, + &Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true), + ) + }) + .await?; + if let Err(error) = + sync_runtime_worker_workdir_attachments_locked(api, worker, &_session_guard).await + { + restore_worker_workdir_connection_stage_locked(api, &link, &_session_guard).await?; return Err(error); } + let stage_api = api.clone(); + let stage_worker = worker.clone(); let remaining = api .store - .list_worker_workdir_links(&api.config.workspace_id, worker)? - .into_iter() - .filter(|link| link.unlinked_at.is_none()) - .filter(|link| { - api.store - .get_workdir_registry(&api.config.workspace_id, &link.workdir_id) - .ok() - .flatten() - .is_none_or(|record| { - !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) - }) - }) - .collect::>(); + .db_executor() + .run(move || active_filesystem_workdir_links(&stage_api, &stage_worker)) + .await?; if remaining.len() == 1 && let Err(error) = - open_current_worker_workdir_session_locked(api, worker, &remaining[0]).await + open_current_worker_workdir_session_locked(api, worker, &remaining[0], &_session_guard) + .await { - api.store.restore_worker_workdir_connection(&link)?; - let _ = sync_runtime_worker_workdir_attachments(api, worker); + restore_worker_workdir_connection_stage_locked(api, &link, &_session_guard).await?; + let _ = sync_runtime_worker_workdir_attachments_locked(api, worker, &_session_guard).await; return Err(error.into()); } if let Err(error) = api .worker_projection - .refresh(&worker) + .refresh_async(&worker) + .await .map_err(ApiError::from) { - api.store.restore_worker_workdir_connection(&link)?; - sync_runtime_worker_workdir_attachments(api, worker)?; - refresh_current_worker_session_locked(api, worker).await?; + restore_worker_workdir_connection_stage_locked(api, &link, &_session_guard).await?; + sync_runtime_worker_workdir_attachments_locked(api, worker, &_session_guard).await?; + refresh_current_worker_session_locked(api, worker, &_session_guard).await?; return Err(error); } Ok(server_api::CurrentWorkerWorkdirAttachmentResponse { @@ -18202,41 +19731,36 @@ async fn detach_current_worker_workdir( }) } -fn validated_current_worker_attachment( +async fn validated_current_worker_attachment_async( api: &WorkspaceApi, worker: &RuntimeWorkerRef, target_workdir: &str, + session_guard: Option<&Arc>>, ) -> ApiResult { - let find = || -> Result> { - Ok(api + for imported in [false, true] { + let stage_api = api.clone(); + let stage_worker = worker.clone(); + let stage_alias = target_workdir.to_string(); + let link = api .store - .list_worker_workdir_links(&api.config.workspace_id, worker)? - .into_iter() - .find(|link| link.unlinked_at.is_none() && link.alias == target_workdir)) - }; - if let Some(link) = find()? { - return Ok(link); - } - - // A Runtime can start the Worker immediately after reserving its local - // bindings, before the outer spawn handler projects them into the Backend. - // Import that exact alias set once; never substitute another attachment. - if !api - .store - .worker_workdir_link_history_exists(&api.config.workspace_id, worker)? - { - let observed_worker = api - .runtime - .worker(worker) - .map_err(|error| error.into_error())?; - if !observed_worker.workdir_attachments.is_empty() { - sync_worker_observation(api, &observed_worker)?; - if let Some(link) = find()? { - return Ok(link); - } + .db_executor() + .run(move || -> Result<_> { + Ok(stage_api + .store + .list_worker_workdir_links(&stage_api.config.workspace_id, &stage_worker)? + .into_iter() + .find(|link| link.unlinked_at.is_none() && link.alias == stage_alias)) + }) + .await?; + if let Some(link) = link { + return Ok(link); + } + if !imported { + // A Runtime may start the Worker before the outer spawn handler + // projects its reserved aliases. Import that exact set only once. + import_current_worker_workdir_observation(api, worker, session_guard).await?; } } - Err(Error::WorkdirAttachmentConflict(format!( "Worker {}:{} has no Workdir attachment alias `{target_workdir}`", worker.runtime_id, worker.worker_id @@ -18345,19 +19869,24 @@ async fn scoped_execute_current_worker_workdir_operation( context.stage = "workspace_scope"; validate_workspace_scope(&api, &path.workspace_id)?; context.stage = "worker_identity"; - let worker = current_worker_identity(&api, &path.workspace_id, &headers)?; + let stage_api = api.clone(); + let worker = api.host_executors.host()?.run(move || { + current_worker_identity(&stage_api, &path.workspace_id, &headers) + }).await?; context.runtime_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.runtime_id)); context.worker_id_hash = Some(crate::worker_source::diagnostic_id_hash(&worker.worker_id)); let target_workdir = request.target_workdir; let result = match request.operation { WorkdirSessionOperation::CommandStart(command) => { let session_lock = current_worker_session_lock(&api, &worker); - let _session_guard = session_lock.lock().await; + let _session_guard = Arc::new(session_lock.lock_owned().await); context.stage = "attachment_validation"; - let link = validated_current_worker_attachment(&api, &worker, &target_workdir)?; + let link = validated_current_worker_attachment_async( + &api, &worker, &target_workdir, Some(&_session_guard), + ).await?; context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id)); context.stage = "session_open"; - let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?; + let source = open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await?; context.stage = "provider_dispatch"; let provider_handle = source .start_command(command) @@ -18389,7 +19918,7 @@ async fn scoped_execute_current_worker_workdir_operation( } WorkdirSessionOperation::CommandStatus(external_handle) => { let (session, provider_handle) = - current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context)?; + current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context).await?; session .command_status(provider_handle) .await @@ -18398,7 +19927,7 @@ async fn scoped_execute_current_worker_workdir_operation( } WorkdirSessionOperation::CommandOutput(mut output) => { let (session, provider_handle) = - current_worker_command_session(&api, &worker, &target_workdir, &output.handle, &mut context)?; + current_worker_command_session(&api, &worker, &target_workdir, &output.handle, &mut context).await?; output.handle = provider_handle; session .command_output(output) @@ -18408,7 +19937,7 @@ async fn scoped_execute_current_worker_workdir_operation( } WorkdirSessionOperation::CommandCancel(external_handle) => { let (session, provider_handle) = - current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context)?; + current_worker_command_session(&api, &worker, &target_workdir, &external_handle, &mut context).await?; session .cancel_command(provider_handle) .await @@ -18429,12 +19958,14 @@ async fn scoped_execute_current_worker_workdir_operation( | WorkdirSessionOperation::Glob(_) | WorkdirSessionOperation::Grep(_)) => { let session_lock = current_worker_session_lock(&api, &worker); - let _session_guard = session_lock.lock().await; + let _session_guard = Arc::new(session_lock.lock_owned().await); context.stage = "attachment_validation"; - let link = validated_current_worker_attachment(&api, &worker, &target_workdir)?; + let link = validated_current_worker_attachment_async( + &api, &worker, &target_workdir, Some(&_session_guard), + ).await?; context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id)); context.stage = "session_open"; - let source = open_current_worker_workdir_session_locked(&api, &worker, &link).await?; + let source = open_current_worker_workdir_session_locked(&api, &worker, &link, &_session_guard).await?; context.stage = "provider_dispatch"; execute_workdir_session_operation(&source, operation) .await @@ -18449,7 +19980,7 @@ async fn scoped_execute_current_worker_workdir_operation( }) } -fn current_worker_command_session( +async fn current_worker_command_session( api: &WorkspaceApi, worker: &RuntimeWorkerRef, target_workdir: &str, @@ -18457,7 +19988,7 @@ fn current_worker_command_session( context: &mut server_api::WorkdirOperationLogContext, ) -> std::result::Result<(WorkdirSessionHandle, CommandHandle), InternalWorkdirOperationError> { context.stage = "attachment_validation"; - let link = validated_current_worker_attachment(api, worker, target_workdir)?; + let link = validated_current_worker_attachment_async(api, worker, target_workdir, None).await?; context.workdir_id_hash = Some(crate::worker_source::diagnostic_id_hash(&link.workdir_id)); context.stage = "command_lookup"; let command = api @@ -18563,8 +20094,29 @@ async fn run_orchestrator_turn_end_hook(api: WorkspaceApi) { }; let mut worker_states = HashMap::new(); while let Some(update) = subscription.recv().await { - if !observe_orchestrator_turn_end(&api, &mut worker_states, update) { - return; + let stage_api = api.clone(); + let result = api.host_executors.host().map(|executor| async move { + executor + .run(move || -> Result<_> { + let keep_running = + observe_orchestrator_turn_end(&stage_api, &mut worker_states, update); + Ok((keep_running, worker_states)) + }) + .await + }); + match result { + Ok(operation) => match operation.await { + Ok((true, states)) => worker_states = states, + Ok((false, _)) => return, + Err(error) => { + tracing::warn!(%error, "Orchestrator hook stopped; durable inputs remain recoverable"); + return; + } + }, + Err(error) => { + tracing::warn!(%error, "Orchestrator hook executor unavailable"); + return; + } } } } @@ -18630,24 +20182,55 @@ fn maybe_dispatch_orchestrator_turn_end( if orchestrator.worker.runtime_id == EMBEDDED_WORKER_RUNTIME_ID && orchestrator.worker.worker_id == worker_id { - dispatch_orchestrator_queue_attention(api, &orchestrator.worker, false); + // Idle can be a reentrant consequence of this very Runtime send. It is + // not evidence of a new input and must not retry a failed send forever. + // Concurrent committed-input notifications independently defer a check. + with_orchestrator_attention_reservation( + api, + &orchestrator.worker, + false, + |state, pending| { + check_orchestrator_assignment_attention(api, &orchestrator.worker, state, pending); + }, + ); } } -fn dispatch_orchestrator_queue_attention( +fn enqueue_orchestrator_assignment_attention( api: &WorkspaceApi, - worker: &RuntimeWorkerRef, - retry_only: bool, -) { - with_orchestrator_attention_reservation(api, worker, retry_only, |state, pending| { - check_orchestrator_queue_attention(api, worker, state, pending); +) -> Option> { + let api = api.clone(); + let (done, completion) = tokio::sync::oneshot::channel(); + match api.host_executors.host().and_then(|executor| { + executor.dispatch(move || { + if let Some(orchestrator) = find_workspace_orchestrator(&api) { + dispatch_orchestrator_assignment_attention(&api, &orchestrator.worker); + } + let _ = done.send(()); + }) + }) { + Ok(()) => Some(completion), + Err(error) => { + tracing::warn!(%error, "Assign notification was not admitted; input remains durable"); + None + } + } +} + +fn dispatch_orchestrator_assignment_attention(api: &WorkspaceApi, worker: &RuntimeWorkerRef) { + with_orchestrator_attention_reservation(api, worker, true, |state, pending| { + check_orchestrator_assignment_attention(api, worker, state, pending); }); } +/// Process-local send exclusion is not backlog authority. Coalesce committed-input/startup/ +/// Restore triggers during a scan and drain them on release, even after an empty snapshot or +/// accepted pending retry. Reentrant idle observations must not loop on a failed send. Losing +/// this cache loses no delegation; the durable scan recovers only unacknowledged inputs. fn with_orchestrator_attention_reservation( api: &WorkspaceApi, worker: &RuntimeWorkerRef, - mut retry_only: bool, + defer_input_check_if_reserved: bool, mut check: impl FnMut( &Arc>, Option, @@ -18657,7 +20240,10 @@ fn with_orchestrator_attention_reservation( loop { let pending = { let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); - if state.sending || (retry_only && state.pending.is_none()) { + if state.sending { + // A no-op scan may already have read its empty snapshot. Retain + // committed input/startup/Restore triggers until its completion. + state.input_recheck |= defer_input_check_if_reserved; return; } state.sending = true; @@ -18668,86 +20254,78 @@ fn with_orchestrator_attention_reservation( if !reservation.complete() { return; } - // Accepted Restore is an explicit recheck even when the current check - // was a no-op or an already-online retry. Never infer it from Idle. - retry_only = false; + // Drain coalesced input triggers even when the first scan was a no-op. + // No recheck is created by a failed send or its own idle observation. } } -fn check_orchestrator_queue_attention( +fn orchestrator_delivery_target( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> Result { + if !find_online_workspace_orchestrator(api).is_some_and(|current| current.worker == *worker) { + return Err(Error::WorkspacePermissionDenied( + "Delivery target is not the current registered Orchestrator".into(), + )); + } + let binding = api + .config_store + .get_workspace_runtime_binding(&api.config.workspace_id, &worker.runtime_id)? + .filter(|binding| binding.revoked_at.is_none()) + .ok_or_else(|| { + Error::WorkspacePermissionDenied("Delivery target has no active Runtime binding".into()) + })?; + Ok(ticket::TicketAssignmentTrackingSourceContext { + actor: "backend:assignment-delivery".into(), + runtime_id: worker.runtime_id.clone(), + worker_id: worker.worker_id.clone(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: binding.binding_id, + }) +} + +fn check_orchestrator_assignment_attention( api: &WorkspaceApi, worker: &RuntimeWorkerRef, state: &Arc>, pending: Option, ) { - // Unknown outcomes keep the original immutable payload and ID, even if the - // backlog or prompt projection changed before this retry. + let Ok(target) = orchestrator_delivery_target(api, worker) else { + return; + }; if let Some(delivery) = pending { - send_orchestrator_attention(api, worker, &state, delivery); - return; - } - let Ok(backend) = browser_ticket_backend(api) else { - return; - }; - let Ok(mut queued) = backend.list(ticket::TicketListQuery::states([ - ticket::TicketListState::Queued, - ])) else { - return; - }; - queued.retain(|ticket| { - orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "queued") - .ok() - .flatten() - .is_some() - }); - let Ok(mut inprogress) = backend.list(ticket::TicketListQuery::states([ - ticket::TicketListState::InProgress, - ])) else { - return; - }; - inprogress.retain(|ticket| { - orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "inprogress") - .ok() - .flatten() - .is_some() - }); - queued.extend(inprogress); - queued.sort_by(|left, right| left.id.cmp(&right.id)); - if queued.is_empty() { - state.lock().unwrap_or_else(|p| p.into_inner()).accepted = None; - return; - } - let fingerprint = queued - .iter() - .map(|ticket| ticket.id.as_str()) - .collect::>() - .join("|"); - if state - .lock() - .unwrap_or_else(|p| p.into_inner()) - .accepted - .as_ref() - .map(|delivery| delivery.fingerprint.as_str()) - == Some(fingerprint.as_str()) - { - return; - } - - let attention_context = match orchestrator_queue_attention_context( - &api.config.workspace_id, - &api.config.workspace_id, - &queued, - ) { - Ok(context) => context, - Err(error) => { - tracing::warn!( - workspace_id = %api.config.workspace_id, - candidate_count = queued.len(), - diagnostic = error, - "orchestrator backlog attention projection rejected" - ); - return; + // Validate current identity before retrying an immutable request. Clear, + // reissue, close and Worker replacement cannot revive an old delivery. + if api + .store + .reserve_ticket_assignment_delivery( + &api.config.workspace_id, + &delivery.input.assignment_id, + &delivery.input.input_event_id, + &target, + ) + .is_ok() + { + send_orchestrator_attention(api, worker, state, delivery); + if state + .lock() + .unwrap_or_else(|p| p.into_inner()) + .pending + .is_some() + { + return; // Unknown/failed acceptance needs an explicit later trigger. + } + } else { + state.lock().unwrap_or_else(|p| p.into_inner()).pending = None; } + // Discarding or acknowledging an old retry must not consume this new- + // input trigger. Continue the common scan in the same reservation. + } + let Ok(inputs) = api + .store + .list_pending_ticket_assignment_deliveries(&api.config.workspace_id, 20) + else { + return; }; let Ok(Some(config_state)) = api .config_store @@ -18764,27 +20342,66 @@ fn check_orchestrator_queue_attention( let Ok(catalog) = worker::PromptCatalog::from_projection(projection.catalog().clone()) else { return; }; - let content = match catalog.orchestrator_queue_attention( - worker::OrchestratorQueueAttentionPrompt::Server, - &attention_context, - ) { - Ok(content) => content, - Err(error) => { - tracing::warn!( - workspace_id = %api.config.workspace_id, - diagnostic = %error, - "orchestrator backlog attention rendering failed" - ); - return; + for input in inputs { + // Reserve from the same pending-input contract, rechecking current + // assignment and target binding under the write lock before any I/O. + let Ok(input) = api.store.reserve_ticket_assignment_delivery( + &api.config.workspace_id, + &input.assignment_id, + &input.input_event_id, + &target, + ) else { + continue; + }; + let Ok(ticket) = api.authority.ticket(&input.ticket_id) else { + continue; + }; + let Ok(projected) = + worker::OrchestratorQueueAttentionTicket::new(ticket.resource_key, ticket.title) + else { + continue; + }; + let context = + worker::OrchestratorQueueAttentionContext::new(vec![projected.with_assignment_input( + &input.assignment_id, + &input.input_event_id, + input.waiting_reason.as_deref(), + input.next_action.as_deref(), + )]); + let Ok(content) = catalog.orchestrator_queue_attention( + worker::OrchestratorQueueAttentionPrompt::Server, + &context, + ) else { + continue; + }; + // The complete rendered request, including title/catalog projection, is + // pinned before dispatch; restart retries never render a new payload + // under an already accepted Runtime request id. + let Ok(content) = api.store.prepare_ticket_assignment_delivery_content( + &api.config.workspace_id, + &input.delivery_id, + &content, + &target, + ) else { + continue; + }; + let delivery = OrchestratorAttentionDelivery { + fingerprint: input.delivery_id.clone(), + request_id: input.request_id.clone(), + content, + input, + }; + state.lock().unwrap_or_else(|p| p.into_inner()).pending = Some(delivery.clone()); + send_orchestrator_attention(api, worker, state, delivery); + if state + .lock() + .unwrap_or_else(|p| p.into_inner()) + .pending + .is_some() + { + break; } - }; - let delivery = OrchestratorAttentionDelivery { - fingerprint, - request_id: uuid::Uuid::now_v7().to_string(), - content, - }; - state.lock().unwrap_or_else(|p| p.into_inner()).pending = Some(delivery.clone()); - send_orchestrator_attention(api, worker, &state, delivery); + } } fn send_orchestrator_attention( @@ -18793,30 +20410,76 @@ fn send_orchestrator_attention( state: &Mutex, delivery: OrchestratorAttentionDelivery, ) { - // Bounded delivery-side retry, not an Idle-snapshot retry. Rejection and - // transport ambiguity leave pending available for the next attempt. + tracing::debug!(delivery_id = %delivery.fingerprint, assignment_id = %delivery.input.assignment_id, "dispatch committed assignment input"); for _ in 0..2 { - let accepted = api - .runtime - .send_input( - worker, - WorkerInputRequest { - kind: WorkerInputKind::Notify, - content: delivery.content.clone(), - submission_request_id: Some(delivery.request_id.clone()), - segments: None, - }, - ) - .is_ok_and(|result| result.state == InternalWorkerOperationState::Accepted); - if accepted { - let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); - state.accepted = Some(delivery); - state.pending = None; + let Ok(target) = orchestrator_delivery_target(api, worker) else { return; + }; + if api + .store + .reserve_ticket_assignment_delivery( + &api.config.workspace_id, + &delivery.input.assignment_id, + &delivery.input.input_event_id, + &target, + ) + .is_err() + { + state.lock().unwrap_or_else(|p| p.into_inner()).pending = None; + return; + } + let result = api.runtime.send_input( + worker, + WorkerInputRequest { + kind: WorkerInputKind::Notify, + content: delivery.content.clone(), + submission_request_id: Some(delivery.request_id.clone()), + segments: None, + }, + ); + if result + .as_ref() + .is_ok_and(|result| result.state == InternalWorkerOperationState::Accepted) + { + if api + .store + .ack_ticket_assignment_delivery( + &api.config.workspace_id, + &delivery.input.delivery_id, + &target, + ) + .is_ok() + { + let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); + state.accepted = Some(delivery); + state.pending = None; + } + return; + } + if let Some(error_category) = ticket_notification_delivery_error_category(&result) { + tracing::warn!( + event = "ticket_assignment_delivery_pending", + workspace_id = %api.config.workspace_id, + assignment_id = %delivery.input.assignment_id, + input_event_id = %delivery.input.input_event_id, + delivery_id = %delivery.input.delivery_id, + recipient_runtime_id = %worker.runtime_id, + recipient_worker_id = %worker.worker_id, + error_category, + "committed delegation input remains pending; retry the saved request" + ); + emit_ticket_notification_delivery_warning(TicketNotificationDeliveryWarning::new( + &api.config.workspace_id, + &delivery.input.ticket_id, + "open", + worker, + error_category, + )); } } } +#[cfg(test)] fn orchestrator_queue_attention_context( expected_workspace_id: &str, candidate_workspace_id: &str, @@ -18898,15 +20561,20 @@ async fn scoped_get_memory_document( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let document = api.authority.memory_document()?; - Ok(Json(MemoryDocumentResponse { - bytes: document.body_md.len(), - body_md: document.body_md, - created_at: document.created_at, - updated_at: document.updated_at, - record_source: document.record_source, - })) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let document = api.authority.memory_document()?; + Ok(Json(MemoryDocumentResponse { + bytes: document.body_md.len(), + body_md: document.body_md, + created_at: document.created_at, + updated_at: document.updated_at, + record_source: document.record_source, + })) + }) + .await } async fn scoped_reset_legacy_memory( @@ -18929,13 +20597,18 @@ async fn scoped_reset_legacy_memory( )) .into()); } - let counts = api.store.reset_legacy_memory(&path.workspace_id)?; - Ok(Json(server_api::LegacyMemoryResetResponse { - workspace_id: path.workspace_id, - documents_deleted: counts.documents, - staging_records_deleted: counts.staging_records, - staging_resolutions_deleted: counts.staging_resolutions, - })) + api.store + .db_executor() + .run(move || { + let counts = api.store.reset_legacy_memory(&path.workspace_id)?; + Ok(Json(server_api::LegacyMemoryResetResponse { + workspace_id: path.workspace_id, + documents_deleted: counts.documents, + staging_records_deleted: counts.staging_records, + staging_resolutions_deleted: counts.staging_resolutions, + })) + }) + .await } async fn scoped_list_memory_staging( @@ -18943,13 +20616,20 @@ async fn scoped_list_memory_staging( AxumPath(path): AxumPath, Query(query): Query, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(list_memory_staging_from_authority( - &api.authority, - query.limit.map(usize::try_from).transpose().map_err(|_| { - Error::InvalidInput("Memory staging limit exceeds this host's usize range".to_string()) - })?, - )?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(list_memory_staging_from_authority( + &api.authority, + query.limit.map(usize::try_from).transpose().map_err(|_| { + Error::InvalidInput( + "Memory staging limit exceeds this host's usize range".to_string(), + ) + })?, + )?)) + }) + .await } async fn scoped_memory_backend_operation( @@ -18958,21 +20638,26 @@ async fn scoped_memory_backend_operation( _context: server_api::ServerRequestContext, Json(operation): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - // The legacy memory document edit is a read/replace/write across store - // calls. Keep that resource atomic without serializing unrelated requests. - let _memory_guard = api - .memory_operation_lock - .lock() - .expect("Memory operation lock poisoned"); - let result = execute_memory_backend_operation_with_authority(&api.authority, operation); - let response = match result { - Ok(result) => MemoryBackendHttpResponse::Ok { result }, - Err(error) => MemoryBackendHttpResponse::Error { - message: sanitize_backend_error(&error.to_string()), - }, - }; - Ok(Json(response)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + // The legacy memory document edit is a read/replace/write across store + // calls. Keep that resource atomic without serializing unrelated requests. + let _memory_guard = api + .memory_operation_lock + .lock() + .expect("Memory operation lock poisoned"); + let result = execute_memory_backend_operation_with_authority(&api.authority, operation); + let response = match result { + Ok(result) => MemoryBackendHttpResponse::Ok { result }, + Err(error) => MemoryBackendHttpResponse::Error { + message: sanitize_backend_error(&error.to_string()), + }, + }; + Ok(Json(response)) + }) + .await } const SUBJEKTIV_SINGLETON_PREFIX: &str = crate::subjektiv::SUBJECT_WORKER_SINGLETON_PREFIX; @@ -18980,6 +20665,67 @@ const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = crate::hosts::SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX; const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation"; +/// Finite Host and cleanup owners, each independent of the pure DB callback lane. +/// Runtime/network workflows must not monopolize the sole DB worker or re-admit to their +/// own Host lane, even indirectly via a mandatory Runtime callback. Transactions end before +/// I/O. Teardown uses cleanup so waits cannot starve ordinary Host stop/control operations. +/// Production shares these owners; fixture constructors inject a lazy owner shared by child +/// Workspaces and weak adapters, never a process-global fixture cache. +#[derive(Clone, Default)] +struct HostExecutors { + host: Arc>>, + cleanup: Arc>>, +} + +impl HostExecutors { + fn shared() -> Self { + static SHARED: std::sync::OnceLock = std::sync::OnceLock::new(); + SHARED.get_or_init(Self::default).clone() + } + + fn lane( + owner: &std::sync::OnceLock>, + ) -> Result { + owner + .get_or_init(|| { + crate::db_executor::DbExecutor::new(crate::db_executor::DbExecutorLimits { + workers: std::num::NonZeroUsize::new(2).unwrap(), + queued: std::num::NonZeroUsize::new(16).unwrap(), + }) + .ok() + }) + .clone() + .ok_or(Error::DbExecutorUnavailable) + } + + fn host(&self) -> Result { + Self::lane(&self.host) + } + fn cleanup(&self) -> Result { + Self::lane(&self.cleanup) + } +} + +/// Poll mixed lifecycle workflows on finite Host ownership using the original Tokio handle, +/// never a replacement runtime for cached services. Resource/control/session guards belong +/// to the accepted job; cancelling its waiter releases neither guards nor ownership. +/// Keep commit, ordered local publication and response observation in that same job; admit +/// before mutation so overload cannot commit a change then fail on response admission. +/// No SQL transaction contains an await/Runtime call. Already-owned workflows call their +/// on-Host helpers directly rather than awaiting nested admission to this lane. +async fn run_host_workflow( + executor: crate::db_executor::DbExecutor, + future: impl std::future::Future> + Send + 'static, +) -> std::result::Result +where + T: Send + 'static, + E: From + Send + 'static, +{ + let runtime = + tokio::runtime::Handle::try_current().map_err(|_| E::from(Error::DbExecutorUnavailable))?; + executor.run(move || runtime.block_on(future)).await +} + fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult { crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) .map_err(|error| ApiError::from(Error::Store(error.to_string()))) @@ -19048,11 +20794,30 @@ fn require_dedicated_subjektiv_consolidation_worker( } } -fn subjektiv_subject_scope( +/// Store-only scope authority for Subject, Job consolidation and legacy consolidation. +/// Mandatory Session attribution and Subject-only memory use independent DB ownership and +/// reject both consolidation roles before Runtime profile/tag lookup. A create/restore parent +/// can hold Host while awaiting these callbacks, so resident context must not queue behind it. +/// Mixed legacy consolidation, public session reads/search, response projection and general +/// memory staging remain on Host when they can call Runtime/network outside transactions. +/// This separation grants no new permission to reenter Workspace deletion drain. +enum SubjektivStoredScopeAuthority { + Subject, + JobConsolidation, + LegacyConsolidation { singleton_key: String }, +} + +struct SubjektivStoredWorkerScope { + subject_id: String, + worker: RuntimeWorkerRef, + authority: SubjektivStoredScopeAuthority, +} + +fn subjektiv_worker_scope_from_store( api: &WorkspaceApi, workspace_id: &str, context: &server_api::ServerRequestContext, -) -> ApiResult<(String, RuntimeWorkerRef, SubjektivWorkerAuthority)> { +) -> ApiResult { let source = context.runtime_source.as_ref().ok_or_else(|| { Error::WorkspacePermissionDenied( "subjektiv operations require authenticated Runtime-owned source authority".to_string(), @@ -19065,11 +20830,11 @@ fn subjektiv_subject_scope( })?; let worker = RuntimeWorkerRef::new(&source.runtime_id, worker_id); if let Some(grant) = subjektiv_job::active_grant(api, workspace_id, &worker)? { - return Ok(( - grant.subject_id, + return Ok(SubjektivStoredWorkerScope { + subject_id: grant.subject_id, worker, - SubjektivWorkerAuthority::Consolidation, - )); + authority: SubjektivStoredScopeAuthority::JobConsolidation, + }); } let lease = api .store @@ -19080,15 +20845,47 @@ fn subjektiv_subject_scope( ) })?; let (subject_id, authority) = subjektiv_scope_from_singleton_key(&lease.key)?; - if authority == SubjektivWorkerAuthority::Consolidation { - let mut summary = api - .runtime - .worker(&worker) - .map_err(|error| error.into_error())?; - summary.singleton_key = Some(lease.key.clone()); - require_dedicated_subjektiv_consolidation_worker(&summary, workspace_id, &lease.key)?; - } - Ok((subject_id.to_string(), worker, authority)) + let authority = match authority { + SubjektivWorkerAuthority::Subject => SubjektivStoredScopeAuthority::Subject, + SubjektivWorkerAuthority::Consolidation => { + SubjektivStoredScopeAuthority::LegacyConsolidation { + singleton_key: lease.key.clone(), + } + } + }; + Ok(SubjektivStoredWorkerScope { + subject_id: subject_id.to_owned(), + worker, + authority, + }) +} + +// Mixed callers run this complete policy boundary on Host. Only legacy +// consolidation authority needs synchronous Runtime profile/tag verification. +fn subjektiv_subject_scope( + api: &WorkspaceApi, + workspace_id: &str, + context: &server_api::ServerRequestContext, +) -> ApiResult<(String, RuntimeWorkerRef, SubjektivWorkerAuthority)> { + let scope = subjektiv_worker_scope_from_store(api, workspace_id, context)?; + let authority = match scope.authority { + SubjektivStoredScopeAuthority::Subject => SubjektivWorkerAuthority::Subject, + SubjektivStoredScopeAuthority::JobConsolidation => SubjektivWorkerAuthority::Consolidation, + SubjektivStoredScopeAuthority::LegacyConsolidation { singleton_key } => { + let mut summary = api + .runtime + .worker(&scope.worker) + .map_err(|error| error.into_error())?; + summary.singleton_key = Some(singleton_key.clone()); + require_dedicated_subjektiv_consolidation_worker( + &summary, + workspace_id, + &singleton_key, + )?; + SubjektivWorkerAuthority::Consolidation + } + }; + Ok((scope.subject_id, scope.worker, authority)) } fn require_subjektiv_worker_authority( @@ -19112,8 +20909,8 @@ fn subjektiv_session_attribution( context: &server_api::ServerRequestContext, session_id: &str, ) -> ApiResult { - let (subject_id, worker, authority) = subjektiv_subject_scope(api, workspace_id, context)?; - if authority != SubjektivWorkerAuthority::Subject { + let scope = subjektiv_worker_scope_from_store(api, workspace_id, context)?; + if !matches!(scope.authority, SubjektivStoredScopeAuthority::Subject) { return Err(Error::WorkspacePermissionDenied( "subject Session attribution requires the current subject Worker".into(), ) @@ -19124,9 +20921,9 @@ fn subjektiv_session_attribution( return Err(Error::InvalidInput("committed session_id is invalid".to_string()).into()); } crate::subjektiv::SubjectSessionAttribution::new( - subject_id, - worker.runtime_id, - worker.worker_id, + scope.subject_id, + scope.worker.runtime_id, + scope.worker.worker_id, session_id, ) .map_err(|error| Error::InvalidInput(error.to_string()).into()) @@ -19197,18 +20994,23 @@ async fn scoped_create_subjektiv_subject( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { - validate_workspace_scope(&api, &path.workspace_id)?; - let role = crate::subjektiv::SubjectRole::new(request.role) - .map_err(|error| Error::InvalidInput(error.to_string()))?; - crate::subjektiv::validate_subject_behavior(&request.behavior_md) - .map_err(|error| Error::InvalidInput(error.to_string()))?; - let subject = open_subjektiv_store(&api)? - .create_subject_with_behavior(role, request.behavior_md) - .map_err(subjektiv_store_error)?; - Ok(( - StatusCode::CREATED, - Json(subjektiv_subject_response(&api, subject)?), - )) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let role = crate::subjektiv::SubjectRole::new(request.role) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + crate::subjektiv::validate_subject_behavior(&request.behavior_md) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + let subject = open_subjektiv_store(&api)? + .create_subject_with_behavior(role, request.behavior_md) + .map_err(subjektiv_store_error)?; + Ok(( + StatusCode::CREATED, + Json(subjektiv_subject_response(&api, subject)?), + )) + }) + .await } #[derive(Debug, Serialize, Deserialize)] @@ -19224,76 +21026,86 @@ async fn scoped_list_subjektiv_subjects( AxumPath(path): AxumPath, Query(query): Query, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let limit = query.limit.unwrap_or(20); - if limit == 0 || limit > server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT { - return Err(Error::InvalidInput(format!( - "subject list limit must be within 1..={}", - server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT - )) - .into()); - } - let cursor = query - .cursor - .as_deref() - .map(|cursor| decode_subjektiv_cursor::("subjects", cursor)) - .transpose()?; - if cursor - .as_ref() - .is_some_and(|cursor| cursor.workspace_id != path.workspace_id) - { - return Err(Error::InvalidInput( - "subjektiv subject cursor does not match Workspace".to_string(), - ) - .into()); - } - let page = open_subjektiv_store(&api)? - .list_subjects_after( - limit, - cursor + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let limit = query.limit.unwrap_or(20); + if limit == 0 || limit > server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT { + return Err(Error::InvalidInput(format!( + "subject list limit must be within 1..={}", + server_api::SUBJEKTIV_BROWSER_MAX_LIST_LIMIT + )) + .into()); + } + let cursor = query + .cursor + .as_deref() + .map(|cursor| decode_subjektiv_cursor::("subjects", cursor)) + .transpose()?; + if cursor .as_ref() - .map(|cursor| (cursor.created_at.as_str(), cursor.subject_id.as_str())), - ) - .map_err(subjektiv_store_error)?; - let next_cursor = if page.has_more { - page.items - .last() - .map(|subject| { - encode_subjektiv_cursor( - "subjects", - &SubjektivSubjectCursor { - workspace_id: path.workspace_id.clone(), - created_at: subject.created_at.clone(), - subject_id: subject.id.clone(), - }, + .is_some_and(|cursor| cursor.workspace_id != path.workspace_id) + { + return Err(Error::InvalidInput( + "subjektiv subject cursor does not match Workspace".to_string(), ) - }) - .transpose()? - } else { - None - }; - Ok(Json(server_api::SubjektivSubjectListResponse { - limit, - items: page - .items - .into_iter() - .map(|subject| subjektiv_subject_response(&api, subject)) - .collect::>>()?, - next_cursor, - has_more: page.has_more, - })) + .into()); + } + let page = open_subjektiv_store(&api)? + .list_subjects_after( + limit, + cursor + .as_ref() + .map(|cursor| (cursor.created_at.as_str(), cursor.subject_id.as_str())), + ) + .map_err(subjektiv_store_error)?; + let next_cursor = if page.has_more { + page.items + .last() + .map(|subject| { + encode_subjektiv_cursor( + "subjects", + &SubjektivSubjectCursor { + workspace_id: path.workspace_id.clone(), + created_at: subject.created_at.clone(), + subject_id: subject.id.clone(), + }, + ) + }) + .transpose()? + } else { + None + }; + Ok(Json(server_api::SubjektivSubjectListResponse { + limit, + items: page + .items + .into_iter() + .map(|subject| subjektiv_subject_response(&api, subject)) + .collect::>>()?, + next_cursor, + has_more: page.has_more, + })) + }) + .await } async fn scoped_get_subjektiv_subject( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let subject = open_subjektiv_store(&api)? - .subject(&path.subject_id) - .map_err(|error| Error::Store(error.to_string()))? - .ok_or_else(|| Error::SubjektivSubjectNotFound(path.subject_id.clone()))?; - Ok(Json(subjektiv_subject_response(&api, subject)?)) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let subject = open_subjektiv_store(&api)? + .subject(&path.subject_id) + .map_err(|error| Error::Store(error.to_string()))? + .ok_or_else(|| Error::SubjektivSubjectNotFound(path.subject_id.clone()))?; + Ok(Json(subjektiv_subject_response(&api, subject)?)) + }) + .await } async fn scoped_update_subjektiv_subject_behavior( @@ -19301,17 +21113,22 @@ async fn scoped_update_subjektiv_subject_behavior( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - crate::subjektiv::validate_subject_behavior(&request.behavior_md) - .map_err(|error| Error::InvalidInput(error.to_string()))?; - let subject = open_subjektiv_store(&api)? - .update_subject_behavior( - &path.subject_id, - &request.expected_behavior_md, - request.behavior_md, - ) - .map_err(subjektiv_store_error)?; - Ok(Json(subjektiv_subject_response(&api, subject)?)) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + crate::subjektiv::validate_subject_behavior(&request.behavior_md) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + let subject = open_subjektiv_store(&api)? + .update_subject_behavior( + &path.subject_id, + &request.expected_behavior_md, + request.behavior_md, + ) + .map_err(subjektiv_store_error)?; + Ok(Json(subjektiv_subject_response(&api, subject)?)) + }) + .await } fn require_subjektiv_subject( @@ -19332,47 +21149,53 @@ async fn scoped_get_subjektiv_resident_surface( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let store = open_subjektiv_store(&api)?; - require_subjektiv_subject(&store, &path.subject_id)?; - let resident = store - .resident_surface(&path.subject_id) - .map_err(subjektiv_store_error)?; - let availability = match resident.availability { - crate::subjektiv::SurfaceAvailability::Ungenerated => { - server_api::SubjektivResidentSurfaceAvailability::Ungenerated - } - crate::subjektiv::SurfaceAvailability::Stale => { - server_api::SubjektivResidentSurfaceAvailability::Stale - } - crate::subjektiv::SurfaceAvailability::Failed => { - server_api::SubjektivResidentSurfaceAvailability::Failed - } - crate::subjektiv::SurfaceAvailability::Ready => { - server_api::SubjektivResidentSurfaceAvailability::Ready - } - }; - let snapshot = resident - .snapshot - .map(|snapshot| server_api::SubjektivResidentSurfaceSnapshot { - snapshot_id: snapshot.id, - body_md: snapshot.body_md, - memory_refs: snapshot - .memory_refs - .into_iter() - .map(|reference| server_api::SubjektivMemoryChangeRef { - memory_id: reference.memory_id, - change_id: reference.change_id, - }) - .collect(), - built_from_memory_fingerprint: snapshot.built_from_memory_fingerprint, - created_at: snapshot.created_at, - }); - Ok(Json(server_api::SubjektivResidentSurfaceResponse { - subject_id: path.subject_id, - availability, - snapshot, - })) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + let resident = store + .resident_surface(&path.subject_id) + .map_err(subjektiv_store_error)?; + let availability = match resident.availability { + crate::subjektiv::SurfaceAvailability::Ungenerated => { + server_api::SubjektivResidentSurfaceAvailability::Ungenerated + } + crate::subjektiv::SurfaceAvailability::Stale => { + server_api::SubjektivResidentSurfaceAvailability::Stale + } + crate::subjektiv::SurfaceAvailability::Failed => { + server_api::SubjektivResidentSurfaceAvailability::Failed + } + crate::subjektiv::SurfaceAvailability::Ready => { + server_api::SubjektivResidentSurfaceAvailability::Ready + } + }; + let snapshot = + resident + .snapshot + .map(|snapshot| server_api::SubjektivResidentSurfaceSnapshot { + snapshot_id: snapshot.id, + body_md: snapshot.body_md, + memory_refs: snapshot + .memory_refs + .into_iter() + .map(|reference| server_api::SubjektivMemoryChangeRef { + memory_id: reference.memory_id, + change_id: reference.change_id, + }) + .collect(), + built_from_memory_fingerprint: snapshot.built_from_memory_fingerprint, + created_at: snapshot.created_at, + }); + Ok(Json(server_api::SubjektivResidentSurfaceResponse { + subject_id: path.subject_id, + availability, + snapshot, + })) + }) + .await } async fn scoped_list_subjektiv_memories( @@ -19380,14 +21203,19 @@ async fn scoped_list_subjektiv_memories( AxumPath(path): AxumPath, Query(query): Query, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let store = open_subjektiv_store(&api)?; - require_subjektiv_subject(&store, &path.subject_id)?; - Ok(Json(subjektiv_memory_query( - &store, - &path.subject_id, - query, - )?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + Ok(Json(subjektiv_memory_query( + &store, + &path.subject_id, + query, + )?)) + }) + .await } async fn scoped_get_subjektiv_memory( @@ -19395,21 +21223,26 @@ async fn scoped_get_subjektiv_memory( AxumPath(path): AxumPath, Query(query): Query, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let store = open_subjektiv_store(&api)?; - require_subjektiv_subject(&store, &path.subject_id)?; - Ok(Json(subjektiv_memory_read( - &store, - &path.subject_id, - server_api::SubjektivMemoryReadRequest { - memory_id: path.memory_id, - change_id: query.change_id, - offset: query.offset, - byte_offset: query.byte_offset, - limit: query.limit, - evidence_cursor: query.evidence_cursor, - }, - )?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + Ok(Json(subjektiv_memory_read( + &store, + &path.subject_id, + server_api::SubjektivMemoryReadRequest { + memory_id: path.memory_id, + change_id: query.change_id, + offset: query.offset, + byte_offset: query.byte_offset, + limit: query.limit, + evidence_cursor: query.evidence_cursor, + }, + )?)) + }) + .await } async fn scoped_list_subjektiv_memory_changes( @@ -19417,31 +21250,41 @@ async fn scoped_list_subjektiv_memory_changes( AxumPath(path): AxumPath, Query(query): Query, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let store = open_subjektiv_store(&api)?; - require_subjektiv_subject(&store, &path.subject_id)?; - Ok(Json(subjektiv_memory_list_changes( - &store, - &path.subject_id, - server_api::SubjektivMemoryListChangesRequest { - memory_id: path.memory_id, - limit: query.limit, - cursor: query.cursor, - }, - )?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let store = open_subjektiv_store(&api)?; + require_subjektiv_subject(&store, &path.subject_id)?; + Ok(Json(subjektiv_memory_list_changes( + &store, + &path.subject_id, + server_api::SubjektivMemoryListChangesRequest { + memory_id: path.memory_id, + limit: query.limit, + cursor: query.cursor, + }, + )?)) + }) + .await } async fn scoped_retire_subjektiv_subject( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let subject = open_subjektiv_store(&api)? - .retire_subject(&path.subject_id) - .map_err(subjektiv_store_error)?; - // Retirement deliberately does not stop or remove the current Worker. The - // Worker lease and subject lifecycle are independent authorities. - Ok(Json(subjektiv_subject_response(&api, subject)?)) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let subject = open_subjektiv_store(&api)? + .retire_subject(&path.subject_id) + .map_err(subjektiv_store_error)?; + // Retirement deliberately does not stop or remove the current Worker. The + // Worker lease and subject lifecycle are independent authorities. + Ok(Json(subjektiv_subject_response(&api, subject)?)) + }) + .await } async fn scoped_stage_subjektiv_candidate( @@ -19450,58 +21293,63 @@ async fn scoped_stage_subjektiv_candidate( context: server_api::ServerRequestContext, Json(mut request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let attribution = require_existing_subjektiv_session_attribution( - &api, - &path.workspace_id, - &context, - &request.session_id, - )?; - let subject_id = attribution.subject_id.clone(); - let session_id = attribution.session_id.as_str(); - for source_ref in &mut request.operation.source_refs { - if source_ref - .session_id - .as_deref() - .is_some_and(|value| value != session_id) - { - return Err(Error::InvalidInput( - "candidate source_ref belongs to a different Session".to_string(), - ) - .into()); - } - source_ref.session_id = Some(session_id.to_string()); - } - if matches!( - request.operation.candidate.kind, - memory::extract::CandidateKind::Preference - ) && request.operation.evidence.iter().any(|evidence| { - !matches!( - evidence.origin.as_ref().map(|origin| &origin.kind), - Some(memory::schema::EvidenceOriginKind::HumanInput) - ) - }) { - return Err(Error::InvalidInput( - "preference candidates require exclusively HumanInput evidence".to_string(), - ) - .into()); - } - let candidate_id = Uuid::now_v7().to_string(); - let record = memory::extract::StagingRecord::from_candidate( - candidate_id, - request.operation.extract_run_id, - request.operation.source, - request.operation.candidate, - request.operation.evidence, - request.operation.source_refs, - ); - let record = crate::subjektiv::SubjectStagingRecord::attach(&subject_id, record); - let (staged, _) = open_subjektiv_store(&api)? - .stage_candidate_with_attribution(record, attribution) - .map_err(|error| Error::InvalidInput(error.to_string()))?; - Ok(Json(server_api::SubjektivStageCandidateResponse { - staging_id: staged.id, - })) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let attribution = require_existing_subjektiv_session_attribution( + &api, + &path.workspace_id, + &context, + &request.session_id, + )?; + let subject_id = attribution.subject_id.clone(); + let session_id = attribution.session_id.as_str(); + for source_ref in &mut request.operation.source_refs { + if source_ref + .session_id + .as_deref() + .is_some_and(|value| value != session_id) + { + return Err(Error::InvalidInput( + "candidate source_ref belongs to a different Session".to_string(), + ) + .into()); + } + source_ref.session_id = Some(session_id.to_string()); + } + if matches!( + request.operation.candidate.kind, + memory::extract::CandidateKind::Preference + ) && request.operation.evidence.iter().any(|evidence| { + !matches!( + evidence.origin.as_ref().map(|origin| &origin.kind), + Some(memory::schema::EvidenceOriginKind::HumanInput) + ) + }) { + return Err(Error::InvalidInput( + "preference candidates require exclusively HumanInput evidence".to_string(), + ) + .into()); + } + let candidate_id = Uuid::now_v7().to_string(); + let record = memory::extract::StagingRecord::from_candidate( + candidate_id, + request.operation.extract_run_id, + request.operation.source, + request.operation.candidate, + request.operation.evidence, + request.operation.source_refs, + ); + let record = crate::subjektiv::SubjectStagingRecord::attach(&subject_id, record); + let (staged, _) = open_subjektiv_store(&api)? + .stage_candidate_with_attribution(record, attribution) + .map_err(|error| Error::InvalidInput(error.to_string()))?; + Ok(Json(server_api::SubjektivStageCandidateResponse { + staging_id: staged.id, + })) + }) + .await } async fn scoped_record_subjektiv_session( @@ -19510,25 +21358,37 @@ async fn scoped_record_subjektiv_session( context: server_api::ServerRequestContext, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let attribution = if request.create_if_missing { - let attribution = - subjektiv_session_attribution(&api, &path.workspace_id, &context, &request.session_id)?; - open_subjektiv_store(&api)? - .record_session_attribution(attribution) - .map_err(|error| Error::InvalidInput(error.to_string()))? - } else { - require_existing_subjektiv_session_attribution( - &api, - &path.workspace_id, - &context, - &request.session_id, - )? - }; - Ok(Json(server_api::SubjektivRecordSessionResponse { - subject_id: attribution.subject_id, - session_id: attribution.session_id, - })) + // Runtime create/restore waits for this mandatory callback while holding + // Host capacity. Its Subject-only authority and whole attribution SQL must + // use the independent DB lane, never the parent's waiting Host lane. + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let attribution = if request.create_if_missing { + let attribution = subjektiv_session_attribution( + &api, + &path.workspace_id, + &context, + &request.session_id, + )?; + open_subjektiv_store(&api)? + .record_session_attribution(attribution) + .map_err(|error| Error::InvalidInput(error.to_string()))? + } else { + require_existing_subjektiv_session_attribution( + &api, + &path.workspace_id, + &context, + &request.session_id, + )? + }; + Ok(Json(server_api::SubjektivRecordSessionResponse { + subject_id: attribution.subject_id, + session_id: attribution.session_id, + })) + }) + .await } const SUBJEKTIV_SESSION_DEFAULT_LIMIT: usize = 20; @@ -19622,50 +21482,61 @@ async fn scoped_subjektiv_session_backend( context: server_api::ServerRequestContext, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let (subject_id, controller, authority) = - subjektiv_subject_scope(&api, &path.workspace_id, &context)?; - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Subject, - "Session history read", - )?; - let store = match open_subjektiv_store(&api) { - Ok(store) => store, - Err(error) => { - return Ok(Json(subjektiv_session_error( - server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, - error.error.to_string(), - ))); - } - }; - let response = match request.operation { - server_api::SubjektivSessionBackendOperation::List(input) => subjektiv_session_list( - &api, - &store, - &path.workspace_id, - &subject_id, - &controller, - input, - ), - server_api::SubjektivSessionBackendOperation::Search(input) => subjektiv_session_search( - &api, - &store, - &path.workspace_id, - &subject_id, - &controller, - input, - ), - server_api::SubjektivSessionBackendOperation::Read(input) => subjektiv_session_read( - &api, - &store, - &path.workspace_id, - &subject_id, - &controller, - input, - ), - }; - Ok(Json(response)) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let (subject_id, controller, authority) = + subjektiv_subject_scope(&api, &path.workspace_id, &context)?; + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "Session history read", + )?; + let store = match open_subjektiv_store(&api) { + Ok(store) => store, + Err(error) => { + return Ok(Json(subjektiv_session_error( + server_api::SubjektivSessionDiagnosticCode::SubjectUnavailable, + error.error.to_string(), + ))); + } + }; + let response = match request.operation { + server_api::SubjektivSessionBackendOperation::List(input) => { + subjektiv_session_list( + &api, + &store, + &path.workspace_id, + &subject_id, + &controller, + input, + ) + } + server_api::SubjektivSessionBackendOperation::Search(input) => { + subjektiv_session_search( + &api, + &store, + &path.workspace_id, + &subject_id, + &controller, + input, + ) + } + server_api::SubjektivSessionBackendOperation::Read(input) => { + subjektiv_session_read( + &api, + &store, + &path.workspace_id, + &subject_id, + &controller, + input, + ) + } + }; + Ok(Json(response)) + }) + .await } fn subjektiv_session_list( @@ -20856,13 +22727,10 @@ async fn scoped_subjektiv_memory_backend( context: server_api::ServerRequestContext, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let (subject_id, source_worker, authority) = - subjektiv_subject_scope(&api, &path.workspace_id, &context)?; - let store = open_subjektiv_store(&api)?; use server_api::SubjektivMemoryBackendOperation as Op; - // Authentication, live Worker/Job authority and committed Session provenance - // remain Backend-owned. Only the domain operation/projection is shared. + // Subject-only operations are complete DB boundaries. This includes resident + // context callbacks during Runtime create/restore; they must not queue behind + // the Host parents waiting for them. Other roles can need legacy Runtime checks. let (expected, label) = match &request.operation { Op::ResidentSummary(_) => ( Some(SubjektivWorkerAuthority::Subject), @@ -20910,61 +22778,93 @@ async fn scoped_subjektiv_memory_backend( ), Op::Query(_) | Op::Read(_) | Op::ListChanges(_) => (None, "recall"), }; - if let Some(expected) = expected { - require_subjektiv_worker_authority(authority, expected, label)?; - } - let host_context = match authority { - SubjektivWorkerAuthority::Subject => { - let attribution = if let Op::StageExplicit(input) = &request.operation { - Some(require_existing_subjektiv_session_attribution( - &api, - &path.workspace_id, - &context, - &input.session_id, - )?) - } else { - None - }; - HostOperationContext::validated_body(&store, &subject_id, attribution)? - } - SubjektivWorkerAuthority::Consolidation => { - match &request.operation { - Op::ReadCandidate(input) => subjektiv_job::require_candidate_grant( - &api, - &path.workspace_id, - &source_worker, - &input.candidate_id, - )?, - Op::DecideCandidate(input) => subjektiv_job::require_candidate_grant( - &api, - &path.workspace_id, - &source_worker, - &input.candidate_id, - )?, - _ => {} - } - let grant = subjektiv_job::active_grant(&api, &path.workspace_id, &source_worker)?; - let job = api - .store - .worker_registry_projection(&path.workspace_id, &source_worker)? - .and_then(|projection| projection.job) - .map(|binding| JobAttemptBinding { - job_id: binding.job_id, - attempt_id: binding.attempt_id, - }); - HostOperationContext::validated_consolidation( - &store, - &subject_id, - grant.map(|g| g.candidate_ids), - job, - )? - } + let executor = if expected == Some(SubjektivWorkerAuthority::Subject) { + api.store.db_executor() + } else { + api.host_executors.host()? }; - Ok(Json(subjektiv::api::execute( - &store, - &host_context, - request.operation, - )?)) + executor + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let (subject_id, source_worker, authority) = if expected + == Some(SubjektivWorkerAuthority::Subject) + { + let scope = subjektiv_worker_scope_from_store(&api, &path.workspace_id, &context)?; + if !matches!(scope.authority, SubjektivStoredScopeAuthority::Subject) { + return Err(Error::WorkspacePermissionDenied(format!( + "subjektiv {label} is not authorized for this Worker role" + )) + .into()); + } + ( + scope.subject_id, + scope.worker, + SubjektivWorkerAuthority::Subject, + ) + } else { + subjektiv_subject_scope(&api, &path.workspace_id, &context)? + }; + let store = open_subjektiv_store(&api)?; + // Authentication, live Worker/Job authority and committed Session provenance + // remain Backend-owned. Only the domain operation/projection is shared. + if let Some(expected) = expected { + require_subjektiv_worker_authority(authority, expected, label)?; + } + let host_context = match authority { + SubjektivWorkerAuthority::Subject => { + let attribution = if let Op::StageExplicit(input) = &request.operation { + Some(require_existing_subjektiv_session_attribution( + &api, + &path.workspace_id, + &context, + &input.session_id, + )?) + } else { + None + }; + HostOperationContext::validated_body(&store, &subject_id, attribution)? + } + SubjektivWorkerAuthority::Consolidation => { + match &request.operation { + Op::ReadCandidate(input) => subjektiv_job::require_candidate_grant( + &api, + &path.workspace_id, + &source_worker, + &input.candidate_id, + )?, + Op::DecideCandidate(input) => subjektiv_job::require_candidate_grant( + &api, + &path.workspace_id, + &source_worker, + &input.candidate_id, + )?, + _ => {} + } + let grant = + subjektiv_job::active_grant(&api, &path.workspace_id, &source_worker)?; + let job = api + .store + .worker_registry_projection(&path.workspace_id, &source_worker)? + .and_then(|projection| projection.job) + .map(|binding| JobAttemptBinding { + job_id: binding.job_id, + attempt_id: binding.attempt_id, + }); + HostOperationContext::validated_consolidation( + &store, + &subject_id, + grant.map(|g| g.candidate_ids), + job, + )? + } + }; + Ok(Json(subjektiv::api::execute( + &store, + &host_context, + request.operation, + )?)) + }) + .await } fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { @@ -21001,13 +22901,22 @@ async fn scoped_subjektiv_memory_consolidation( context: server_api::ServerRequestContext, Json(operation): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let (subject_id, _, authority) = subjektiv_subject_scope(&api, &path.workspace_id, &context)?; - require_subjektiv_worker_authority( - authority, - SubjektivWorkerAuthority::Subject, - "consolidation request", - )?; + let scope_api = api.clone(); + let subject_id = api + .host_executors + .host()? + .run(move || { + validate_workspace_scope(&scope_api, &path.workspace_id)?; + let (subject_id, _, authority) = + subjektiv_subject_scope(&scope_api, &path.workspace_id, &context)?; + require_subjektiv_worker_authority( + authority, + SubjektivWorkerAuthority::Subject, + "consolidation request", + )?; + Ok::<_, ApiError>(subject_id) + }) + .await?; Ok(Json( start_subjektiv_staging_consolidation(api, &subject_id, operation).await?, )) @@ -21068,10 +22977,17 @@ async fn start_subjektiv_staging_consolidation( subject_id: &str, operation: MemoryConsolidateStagingOperation, ) -> ApiResult { - let store = open_subjektiv_store(&api)?; - let (candidate_count, total_bytes) = store - .pending_staging_backlog(subject_id) - .map_err(subjektiv_store_error)?; + let job_api = api.clone(); + let subject = subject_id.to_owned(); + let (candidate_count, total_bytes) = api + .store + .db_executor() + .run(move || { + open_subjektiv_store(&job_api)? + .pending_staging_backlog(&subject) + .map_err(|error| ApiError::from(subjektiv_store_error(error))) + }) + .await?; if !subjektiv_job::retire_legacy_worker(&api, subject_id).await? { return Ok(MemoryConsolidationOutput { status: "skipped_legacy_inflight".into(), @@ -21079,10 +22995,18 @@ async fn start_subjektiv_staging_consolidation( candidate_count, total_bytes, }); } - let surface_availability = store - .resident_surface(subject_id) - .map_err(subjektiv_store_error)? - .availability; + let job_api = api.clone(); + let subject = subject_id.to_owned(); + let surface_availability = api + .store + .db_executor() + .run(move || { + open_subjektiv_store(&job_api)? + .resident_surface(&subject) + .map(|surface| surface.availability) + .map_err(|error| ApiError::from(subjektiv_store_error(error))) + }) + .await?; if !subject_consolidation_required( candidate_count, total_bytes, @@ -21109,7 +23033,11 @@ async fn start_subjektiv_staging_consolidation( }); } - subjektiv_job::dispatch(&api, subject_id, candidate_count, total_bytes) + let subject = subject_id.to_owned(); + api.host_executors + .host()? + .run(move || subjektiv_job::dispatch(&api, &subject, candidate_count, total_bytes)) + .await } async fn scoped_memory_consolidation( @@ -21117,8 +23045,13 @@ async fn scoped_memory_consolidation( AxumPath(path): AxumPath, Json(operation): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(start_memory_staging_consolidation(api, operation)?)) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(start_memory_staging_consolidation(api, operation)?)) + }) + .await } fn start_memory_staging_consolidation( @@ -21318,72 +23251,92 @@ async fn scoped_list_skills( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = api - .config_store - .load_workspace_config(&path.workspace_id)? - .ok_or_else(|| { - Error::RegistryInconsistency(format!( - "Workspace {} has no active config snapshot", - path.workspace_id - )) - })?; - skills::catalog(&state).map(Json).map_err(skill_api_error) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workspace {} has no active config snapshot", + path.workspace_id + )) + })?; + skills::catalog(&state).map(Json).map_err(skill_api_error) + }) + .await } async fn scoped_lint_skills( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = api - .config_store - .load_workspace_config(&path.workspace_id)? - .ok_or_else(|| { - Error::RegistryInconsistency(format!( - "Workspace {} has no active config snapshot", - path.workspace_id - )) - })?; - skills::lint(&state).map(Json).map_err(skill_api_error) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workspace {} has no active config snapshot", + path.workspace_id + )) + })?; + skills::lint(&state).map(Json).map_err(skill_api_error) + }) + .await } async fn scoped_get_skill( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = api - .config_store - .load_workspace_config(&path.workspace_id)? - .ok_or_else(|| { - Error::RegistryInconsistency(format!( - "Workspace {} has no active config snapshot", - path.workspace_id - )) - })?; - skills::detail(&state, &path.name) - .map(Json) - .map_err(skill_api_error) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workspace {} has no active config snapshot", + path.workspace_id + )) + })?; + skills::detail(&state, &path.name) + .map(Json) + .map_err(skill_api_error) + }) + .await } async fn scoped_activate_skill( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = api - .config_store - .load_workspace_config(&path.workspace_id)? - .ok_or_else(|| { - Error::RegistryInconsistency(format!( - "Workspace {} has no active config snapshot", - path.workspace_id - )) - })?; - skills::activation(&state, &path.name) - .map(Json) - .map_err(skill_api_error) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let state = api + .config_store + .load_workspace_config(&path.workspace_id)? + .ok_or_else(|| { + Error::RegistryInconsistency(format!( + "Workspace {} has no active config snapshot", + path.workspace_id + )) + })?; + skills::activation(&state, &path.name) + .map(Json) + .map_err(skill_api_error) + }) + .await } fn skill_api_error(error: skills::SkillError) -> ApiError { @@ -21433,8 +23386,13 @@ async fn scoped_query_objectives( AxumPath(path): AxumPath, Json(query): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(api.authority.query_objectives(query)?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.query_objectives(query)?)) + }) + .await } async fn scoped_show_objective( @@ -21442,10 +23400,15 @@ async fn scoped_show_objective( AxumPath(path): AxumPath, Json(query): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json( - api.authority.show_objective(&path.objective_id, query)?, - )) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json( + api.authority.show_objective(&path.objective_id, query)?, + )) + }) + .await } async fn scoped_create_objective( @@ -21453,15 +23416,20 @@ async fn scoped_create_objective( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(api.authority.create_objective( - ObjectiveCreateInput { - title: request.title, - body_md: request.body_md, - state: request.state, - linked_tickets: request.linked_tickets, - }, - )?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.create_objective( + ObjectiveCreateInput { + title: request.title, + body_md: request.body_md, + state: request.state, + linked_tickets: request.linked_tickets, + }, + )?)) + }) + .await } async fn scoped_edit_objective( @@ -21469,16 +23437,21 @@ async fn scoped_edit_objective( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(api.authority.edit_objective( - &path.objective_id, - ObjectiveEditInput { - title: request.title, - old_string: request.old_string, - new_string: request.new_string, - replace_all: request.replace_all, - }, - )?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.edit_objective( + &path.objective_id, + ObjectiveEditInput { + title: request.title, + old_string: request.old_string, + new_string: request.new_string, + replace_all: request.replace_all, + }, + )?)) + }) + .await } async fn scoped_set_objective_state( @@ -21486,11 +23459,16 @@ async fn scoped_set_objective_state( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json( - api.authority - .set_objective_state(&path.objective_id, &request.state)?, - )) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json( + api.authority + .set_objective_state(&path.objective_id, &request.state)?, + )) + }) + .await } async fn scoped_link_objective_ticket( @@ -21498,22 +23476,32 @@ async fn scoped_link_objective_ticket( AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(api.authority.link_objective_ticket( - &path.objective_id, - &request.ticket_id, - )?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.link_objective_ticket( + &path.objective_id, + &request.ticket_id, + )?)) + }) + .await } async fn scoped_unlink_objective_ticket( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(api.authority.unlink_objective_ticket( - &path.objective_id, - &path.ticket_id, - )?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.unlink_objective_ticket( + &path.objective_id, + &path.ticket_id, + )?)) + }) + .await } async fn scoped_list_repositories( @@ -21531,56 +23519,61 @@ async fn scoped_create_repository( Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { require_workspace_owner(&api, &path.workspace_id, &actor, "ManageRepositories").await?; + api.store + .db_executor() + .run(move || { + server_api::validate_repository_key(&request.repository_key) + .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; + let repository_key = request.repository_key; + let default_ref = request + .default_ref + .as_deref() + .map(|value| normalize_repository_text(value, "default_ref", 512)) + .transpose()?; + let source = parse_repository_source(&request.source).map_err(|error| { + Error::InvalidInput(format!("invalid repository source: {error}")) + })?; + let now = Utc::now().to_rfc3339(); + let record = RepositoryRecord { + workspace_id: path.workspace_id.clone(), + repository_id: Uuid::now_v7().to_string(), + repository_key: repository_key.clone(), + kind: "git".to_string(), + provider: Some("git".to_string()), + source_fingerprint: repository_source_fingerprint(&source), + source, + default_ref, + observed_status: server_api::RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: now.clone(), + updated_at: now, + }; - server_api::validate_repository_key(&request.repository_key) - .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; - let repository_key = request.repository_key; - let default_ref = request - .default_ref - .as_deref() - .map(|value| normalize_repository_text(value, "default_ref", 512)) - .transpose()?; - let source = parse_repository_source(&request.source) - .map_err(|error| Error::InvalidInput(format!("invalid repository source: {error}")))?; - let now = Utc::now().to_rfc3339(); - let record = RepositoryRecord { - workspace_id: path.workspace_id.clone(), - repository_id: Uuid::now_v7().to_string(), - repository_key: repository_key.clone(), - kind: "git".to_string(), - provider: Some("git".to_string()), - source_fingerprint: repository_source_fingerprint(&source), - source, - default_ref, - observed_status: server_api::RepositoryObservedStatus::Unverified, - observed_at: None, - created_at: now.clone(), - updated_at: now, - }; - - let (status, replayed) = match api.config_store.insert_repository(&record)? { - RepositoryInsertOutcome::Created => (StatusCode::CREATED, false), - RepositoryInsertOutcome::Existing(existing) - if repository_create_intent_matches(&existing, &record) => - { - (StatusCode::OK, true) - } - RepositoryInsertOutcome::Existing(_) => { - return Err(Error::RepositoryConflict(format!( + let (status, replayed) = match api.config_store.insert_repository(&record)? { + RepositoryInsertOutcome::Created => (StatusCode::CREATED, false), + RepositoryInsertOutcome::Existing(existing) + if repository_create_intent_matches(&existing, &record) => + { + (StatusCode::OK, true) + } + RepositoryInsertOutcome::Existing(_) => { + return Err(Error::RepositoryConflict(format!( "Repository key {repository_key} already exists with different registration intent" )) .into()); - } - }; + } + }; - Ok(( - status, - Json(CreateWorkspaceRepositoryResponse { - workspace_id: path.workspace_id, - repository_key, - replayed, - }), - )) + Ok(( + status, + Json(CreateWorkspaceRepositoryResponse { + workspace_id: path.workspace_id, + repository_key, + replayed, + }), + )) + }) + .await } fn normalize_repository_text(value: &str, field: &str, max_len: usize) -> Result { @@ -21622,7 +23615,10 @@ async fn scoped_probe_repository_ssh_connection( ) -> ApiResult> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; require_active_workspace_runtime_binding(&api, &request.runtime_id).await?; - probe_repository_ssh_connection(&api, &path, &request.runtime_id).map(Json) + api.host_executors + .host()? + .run(move || probe_repository_ssh_connection(&api, &path, &request.runtime_id).map(Json)) + .await } async fn scoped_confirm_repository_ssh_host_trust( @@ -21633,35 +23629,40 @@ async fn scoped_confirm_repository_ssh_host_trust( ) -> ApiResult> { require_manage_repository_secrets(&api, &path.workspace_id, &actor).await?; require_active_workspace_runtime_binding(&api, &request.runtime_id).await?; - let probe = probe_repository_ssh_connection(&api, &path, &request.runtime_id)?; - let candidate = probe - .candidates - .iter() - .find(|candidate| candidate.host_key == request.host_key) - .ok_or_else(|| { - settings_bad_request( - "repository_ssh_host_key_not_observed", - "Selected SSH host key was not presented by the target Runtime", - ) - })?; - if request.expected_host_key_fingerprint != probe.expected_host_key_fingerprint { - return Err(ApiError::from(Error::WorkspaceConfigConflict( - "SSH host trust fingerprint changed after the connection test".to_string(), - ))); - } - let host_trust = api.repository_secrets.put_host_trust( - &path.workspace_id, - PutRepositorySshHostTrustRequest { - operation_id: request.operation_id, - host_trust_id: probe.host_trust_id, - hostname: probe.hostname, - port: probe.port, - host_key: candidate.host_key.clone(), - expected_fingerprint: probe.expected_host_key_fingerprint, - }, - &actor.account_id, - )?; - Ok(Json(host_trust)) + api.host_executors + .host()? + .run(move || { + let probe = probe_repository_ssh_connection(&api, &path, &request.runtime_id)?; + let candidate = probe + .candidates + .iter() + .find(|candidate| candidate.host_key == request.host_key) + .ok_or_else(|| { + settings_bad_request( + "repository_ssh_host_key_not_observed", + "Selected SSH host key was not presented by the target Runtime", + ) + })?; + if request.expected_host_key_fingerprint != probe.expected_host_key_fingerprint { + return Err(ApiError::from(Error::WorkspaceConfigConflict( + "SSH host trust fingerprint changed after the connection test".to_string(), + ))); + } + let host_trust = api.repository_secrets.put_host_trust( + &path.workspace_id, + PutRepositorySshHostTrustRequest { + operation_id: request.operation_id, + host_trust_id: probe.host_trust_id, + hostname: probe.hostname, + port: probe.port, + host_key: candidate.host_key.clone(), + expected_fingerprint: probe.expected_host_key_fingerprint, + }, + &actor.account_id, + )?; + Ok(Json(host_trust)) + }) + .await } fn repository_ssh_connection_trust_state( @@ -22021,6 +24022,9 @@ async fn worker_remove_contract( .map_err(|error| { let status = match error { crate::worker_source::WorkerMutationSourceProofError::Replay => StatusCode::CONFLICT, + crate::worker_source::WorkerMutationSourceProofError::Unavailable => { + StatusCode::SERVICE_UNAVAILABLE + } crate::worker_source::WorkerMutationSourceProofError::Authority(_) => { StatusCode::INTERNAL_SERVER_ERROR } @@ -22035,7 +24039,8 @@ async fn worker_remove_contract( ) })?; let worker = - WorkspaceWorker::resolve(api, &request.target_runtime_id, &request.target_worker_id) + WorkspaceWorker::resolve_async(api, &request.target_runtime_id, &request.target_worker_id) + .await .map_err(|error| match error { Error::UnknownWorker { .. } => server_api::WorkerRemoveApiError::new( StatusCode::NOT_FOUND.as_u16(), @@ -22091,22 +24096,28 @@ async fn scoped_get_workspace_worker( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let worker_ref = api - .store - .resolve_worker_resource_reference(&api.config.workspace_id, &path.worker_ref)? - .ok_or_else(|| Error::UnknownWorker { - worker: RuntimeWorkerRef::new("unknown", &path.worker_ref), - })?; - let workers = workers_response(api.clone())?; - workers - .items - .into_iter() - .find(|worker| { - worker.runtime_id == worker_ref.runtime_id && worker.worker_id == worker_ref.worker_id + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let worker_ref = api + .store + .resolve_worker_resource_reference(&api.config.workspace_id, &path.worker_ref)? + .ok_or_else(|| Error::UnknownWorker { + worker: RuntimeWorkerRef::new("unknown", &path.worker_ref), + })?; + let workers = workers_response(api.clone())?; + workers + .items + .into_iter() + .find(|worker| { + worker.runtime_id == worker_ref.runtime_id + && worker.worker_id == worker_ref.worker_id + }) + .map(Json) + .ok_or_else(|| Error::UnknownWorker { worker: worker_ref }.into()) }) - .map(Json) - .ok_or_else(|| Error::UnknownWorker { worker: worker_ref }.into()) + .await } async fn discover_workspace_workers( @@ -22138,7 +24149,11 @@ async fn discover_workspace_workers( "invalid_workspace_worker_discovery_cursor".to_string(), )) })?; - let workers = workers_response(api)?; + let workers = api + .store + .db_executor() + .run(move || workers_response(api)) + .await?; Ok(workspace_worker_discovery_page( workers.items, query, @@ -22229,8 +24244,13 @@ async fn scoped_workspace_orchestrator_status( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(workspace_orchestrator_response(&api, "observed"))) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(workspace_orchestrator_response(&api, "observed"))) + }) + .await } async fn list_known_workers( @@ -22238,36 +24258,43 @@ async fn list_known_workers( AxumPath(path): AxumPath, headers: HeaderMap, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; - let controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); - let limit = api.config.max_records.clamp(1, 500); - let grants = - api.store - .list_active_worker_control_grants(&path.workspace_id, &controller, limit + 1)?; - let truncated = grants.len() > limit; - let mut items = Vec::with_capacity(grants.len().min(limit)); - for grant in grants.into_iter().take(limit) { - let summary = api - .runtime - .worker(&grant.subject) - .map_err(|error| error.into_error())?; - items.push(server_api::WorkerControlRecord { - subject: server_api::WorkerControlSubject { - runtime_id: grant.subject.runtime_id, - worker_id: grant.subject.worker_id, - }, - relation: grant.relation, - origin: grant.origin, - permissions: grant.permissions, - summary: worker_launch_worker_summary(summary), - }); - } - Ok(Json(server_api::WorkerControlListResponse { - workspace_id: path.workspace_id, - items, - truncated, - })) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; + let controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); + let limit = api.config.max_records.clamp(1, 500); + let grants = api.store.list_active_worker_control_grants( + &path.workspace_id, + &controller, + limit + 1, + )?; + let truncated = grants.len() > limit; + let mut items = Vec::with_capacity(grants.len().min(limit)); + for grant in grants.into_iter().take(limit) { + let summary = api + .runtime + .worker(&grant.subject) + .map_err(|error| error.into_error())?; + items.push(server_api::WorkerControlRecord { + subject: server_api::WorkerControlSubject { + runtime_id: grant.subject.runtime_id, + worker_id: grant.subject.worker_id, + }, + relation: grant.relation, + origin: grant.origin, + permissions: grant.permissions, + summary: worker_launch_worker_summary(summary), + }); + } + Ok(Json(server_api::WorkerControlListResponse { + workspace_id: path.workspace_id, + items, + truncated, + })) + }) + .await } fn scoped_worker_control_operation_id(controller: &RuntimeWorkerRef, operation_id: &str) -> String { @@ -22277,11 +24304,43 @@ fn scoped_worker_control_operation_id(controller: &RuntimeWorkerRef, operation_i ) } +#[cfg(test)] async fn spawn_known_worker( + api: State, + path: AxumPath, + headers: HeaderMap, + request: Json, +) -> ApiResult> { + spawn_known_worker_authenticated(api, path, headers, request, None).await +} + +async fn spawn_known_worker_authenticated( + State(api): State, + AxumPath(path): AxumPath, + headers: HeaderMap, + Json(request): Json, + context: Option<&server_api::ServerRequestContext>, +) -> ApiResult> { + let context = context.cloned(); + run_host_workflow(api.host_executors.host()?, async move { + spawn_known_worker_authenticated_on_host( + State(api), + AxumPath(path), + headers, + Json(request), + context.as_ref(), + ) + .await + }) + .await +} + +async fn spawn_known_worker_authenticated_on_host( State(api): State, AxumPath(path): AxumPath, headers: HeaderMap, Json(mut request): Json, + context: Option<&server_api::ServerRequestContext>, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; @@ -22319,9 +24378,14 @@ async fn spawn_known_worker( operation_id: scoped_worker_control_operation_id(&controller, &operation_id), input_fingerprint, }); - let response = - create_workspace_worker_inner(api.clone(), headers, request, resolved_control_operation) - .await?; + let response = create_workspace_worker_authenticated_on_host( + api.clone(), + headers, + request, + resolved_control_operation, + context, + ) + .await?; if let Err(error) = api .store .create_worker_control_grant(&WorkerControlGrantRecord { @@ -22398,11 +24462,14 @@ async fn send_known_worker_input( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; + let source = + authenticate_worker_mutation_source_owned(&api, &path.workspace_id, &headers).await?; let context = WorkerOperationContext::WorkerControl { controller: RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id), }; - let worker = WorkspaceWorker::resolve(&api, &path.worker.runtime_id, &path.worker.worker_id)?; + let worker = + WorkspaceWorker::resolve_async(&api, &path.worker.runtime_id, &path.worker.worker_id) + .await?; let request = worker_input_request_from_api(request)?; let idle_only = request.kind == WorkerInputKind::User; let result = worker.input(&context, request).await?; @@ -22434,11 +24501,14 @@ async fn cancel_known_worker( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; + let source = + authenticate_worker_mutation_source_owned(&api, &path.workspace_id, &headers).await?; let context = WorkerOperationContext::WorkerControl { controller: RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id), }; - let worker = WorkspaceWorker::resolve(&api, &path.worker.runtime_id, &path.worker.worker_id)?; + let worker = + WorkspaceWorker::resolve_async(&api, &path.worker.runtime_id, &path.worker.worker_id) + .await?; Ok(Json(worker_lifecycle_result_to_api( worker .cancel(&context, worker_lifecycle_request_from_api(request)) @@ -22453,11 +24523,14 @@ async fn stop_known_worker( Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; + let source = + authenticate_worker_mutation_source_owned(&api, &path.workspace_id, &headers).await?; let context = WorkerOperationContext::WorkerControl { controller: RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id), }; - let worker = WorkspaceWorker::resolve(&api, &path.worker.runtime_id, &path.worker.worker_id)?; + let worker = + WorkspaceWorker::resolve_async(&api, &path.worker.runtime_id, &path.worker.worker_id) + .await?; Ok(Json(worker_lifecycle_result_to_api( worker .stop(&context, worker_lifecycle_request_from_api(request)) @@ -22472,7 +24545,8 @@ async fn restore_known_worker( request: server_api::WorkerRestoreRequest, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; + let source = + authenticate_worker_mutation_source_owned(&api, &path.workspace_id, &headers).await?; let context = WorkerOperationContext::WorkerControl { controller: RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id), }; @@ -22491,41 +24565,46 @@ async fn scoped_list_worker_observation_sessions( AxumPath(path): AxumPath, headers: HeaderMap, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; - let controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); - let sessions = api - .store - .list_active_worker_control_grants(&path.workspace_id, &controller, 100)? - .into_iter() - .filter(|grant| { - grant - .permissions - .iter() - .any(|permission| permission == "observe") + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; + let controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); + let sessions = api + .store + .list_active_worker_control_grants(&path.workspace_id, &controller, 100)? + .into_iter() + .filter(|grant| { + grant + .permissions + .iter() + .any(|permission| permission == "observe") + }) + .filter_map(|grant| { + let worker = api.runtime.worker(&grant.subject).ok()?; + if matches!( + worker.state.as_str(), + "stopped" | "failed" | "rejected" | "disconnected" + ) { + return None; + } + Some(server_api::WorkerObservationSubject { + subject: server_api::WorkerObservationSubjectRef::RuntimeWorker { + runtime_id: grant.subject.runtime_id, + worker_id: grant.subject.worker_id, + }, + display_name: worker.display_name, + relation: grant.relation, + status: worker.state, + }) + }) + .collect::>(); + Ok(Json(server_api::WorkerObservationSessionsResponse { + sessions, + })) }) - .filter_map(|grant| { - let worker = api.runtime.worker(&grant.subject).ok()?; - if matches!( - worker.state.as_str(), - "stopped" | "failed" | "rejected" | "disconnected" - ) { - return None; - } - Some(server_api::WorkerObservationSubject { - subject: server_api::WorkerObservationSubjectRef::RuntimeWorker { - runtime_id: grant.subject.runtime_id, - worker_id: grant.subject.worker_id, - }, - display_name: worker.display_name, - relation: grant.relation, - status: worker.state, - }) - }) - .collect::>(); - Ok(Json(server_api::WorkerObservationSessionsResponse { - sessions, - })) + .await } async fn scoped_capture_worker_observation_session( @@ -22535,7 +24614,8 @@ async fn scoped_capture_worker_observation_session( Json(subject): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; + let source = + authenticate_worker_mutation_source_owned(&api, &path.workspace_id, &headers).await?; let server_api::WorkerObservationSubjectRef::RuntimeWorker { runtime_id, worker_id, @@ -22547,20 +24627,47 @@ async fn scoped_capture_worker_observation_session( }; let target = RuntimeWorkerRef::new(runtime_id, worker_id); let controller = RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id); - let grant = authorize_known_worker_permission( - &api, - &path.workspace_id, - &controller, - &target, - "observe", - )?; + let grant_api = api.clone(); + let grant_controller = controller.clone(); + let grant_target = target.clone(); + let workspace_id = path.workspace_id.clone(); + let grant = api + .store + .db_executor() + .run(move || { + authorize_known_worker_permission( + &grant_api, + &workspace_id, + &grant_controller, + &grant_target, + "observe", + ) + }) + .await?; let lock = worker_control_lock(&api, &grant.grant_id); - let _guard = lock.lock().await; - authorize_known_worker_permission(&api, &path.workspace_id, &controller, &target, "observe")?; - let target_summary = api - .runtime - .worker(&target) - .map_err(|error| error.into_error())?; + let guard = lock.lock_owned().await; + let lookup_api = api.clone(); + let lookup_target = target.clone(); + let (_guard, target_summary) = api + .host_executors + .host()? + .run(move || { + // Retain the control guard during the actual re-fence and live lookup, + // even if the observation caller cancels its wait. + authorize_known_worker_permission( + &lookup_api, + &path.workspace_id, + &controller, + &lookup_target, + "observe", + )?; + let target_summary = lookup_api + .runtime + .worker(&lookup_target) + .map_err(|error| error.into_error())?; + Ok::<_, Error>((guard, target_summary)) + }) + .await?; if matches!( target_summary.state.as_str(), "stopped" | "failed" | "rejected" | "disconnected" @@ -22613,6 +24720,9 @@ async fn scoped_start_workspace_orchestrator( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { + api.host_executors.host()?.run(move || { + #[cfg(test)] + tests::seed_test_delivery_binding(&api); validate_workspace_scope(&api, &path.workspace_id)?; let _guard = api.orchestrator_spawn_lock.lock().map_err(|_| { ApiError::with_diagnostics( @@ -22629,8 +24739,9 @@ async fn scoped_start_workspace_orchestrator( if let Some(existing) = find_workspace_orchestrator(&api) { if workspace_orchestrator_is_online(&existing) { drop(_guard); - // Already-online is not a new startup. Only retry outstanding delivery. - dispatch_orchestrator_queue_attention(&api, &existing.worker, true); + // Already-online creation uses the same committed-input scan; neither + // local cache loss nor handled status alone creates new work. + dispatch_orchestrator_assignment_attention(&api, &existing.worker); return Ok(Json(workspace_orchestrator_response(&api, "existing"))); } let attention = orchestrator_attention_state(&api, &existing.worker); @@ -22652,14 +24763,9 @@ async fn scoped_start_workspace_orchestrator( { attention.accepted = None; } - // A reserved caller may already have decided not to send. Carry - // the explicit Restore check through its completion in that case. - if attention.sending { - attention.restore_recheck = true; - } } drop(_guard); - dispatch_orchestrator_queue_attention(&api, &existing.worker, false); + dispatch_orchestrator_assignment_attention(&api, &existing.worker); return Ok(Json(workspace_orchestrator_response(&api, "restored"))); } if !diagnostics_indicate_unrecoverable_pending_workspace_restore(&restored.diagnostics) { @@ -22739,8 +24845,10 @@ async fn scoped_start_workspace_orchestrator( WorkerRegistryDisplayNamePolicy::UseProvided, )?; drop(_guard); - dispatch_orchestrator_queue_attention(&api, &worker.worker, false); + dispatch_orchestrator_assignment_attention(&api, &worker.worker); Ok(Json(workspace_orchestrator_response(&api, disposition))) + + }).await } fn worker_launch_worker_summary(worker: InternalWorkerSummary) -> WorkerLaunchWorkerSummary { @@ -22816,6 +24924,7 @@ fn workspace_orchestrator_is_online(worker: &InternalWorkerSummary) -> bool { matches!(worker.state.as_str(), "idle" | "running" | "paused") } +#[cfg(test)] async fn scoped_create_workspace_worker( State(api): State, AxumPath(path): AxumPath, @@ -22872,13 +24981,18 @@ async fn scoped_list_runtime_working_directories( ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; require_active_workspace_runtime_binding(&api, &path.runtime_id).await?; - let (items, diagnostics) = runtime_working_directory_summaries(&api, &path.runtime_id)?; - let items = items.into_iter().map(Into::into).collect(); - Ok(Json(BrowserWorkingDirectoryListResponse { - workspace_id: api.config.workspace_id.clone(), - items, - diagnostics: working_directory_diagnostics(diagnostics), - })) + api.host_executors + .host()? + .run(move || { + let (items, diagnostics) = runtime_working_directory_summaries(&api, &path.runtime_id)?; + let items = items.into_iter().map(Into::into).collect(); + Ok(Json(BrowserWorkingDirectoryListResponse { + workspace_id: api.config.workspace_id.clone(), + items, + diagnostics: working_directory_diagnostics(diagnostics), + })) + }) + .await } async fn scoped_create_runtime_working_directory( @@ -22902,7 +25016,12 @@ async fn scoped_runtime_working_directory_detail( ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; require_active_workspace_runtime_binding(&api, &path.runtime_id).await?; - working_directory_detail_for_runtime(api, &path.runtime_id, &path.working_directory_id) + api.host_executors + .host()? + .run(move || { + working_directory_detail_for_runtime(api, &path.runtime_id, &path.working_directory_id) + }) + .await } fn workdir_removal_source_actor( @@ -22988,19 +25107,25 @@ async fn cleanup_runtime_working_directory( ) -> ApiResult { validate_workspace_scope(&api, &path.workspace_id)?; require_active_workspace_runtime_binding(&api, &path.runtime_id).await?; - let registered_runtime = registered_workdir_runtime_id(&api, &path.working_directory_id)?; - if registered_runtime != path.runtime_id { - return Err(ApiError::from(Error::WorkspacePermissionDenied( - "Workdir does not belong to the requested Runtime".to_string(), - ))); - } - execute_workdir_removal( - &api, - &path.working_directory_id, - &source_actor, - &request.reason, - ) - .map_err(ApiError::from) + api.host_executors + .host()? + .run(move || { + let registered_runtime = + registered_workdir_runtime_id(&api, &path.working_directory_id)?; + if registered_runtime != path.runtime_id { + return Err(ApiError::from(Error::WorkspacePermissionDenied( + "Workdir does not belong to the requested Runtime".to_string(), + ))); + } + execute_workdir_removal( + &api, + &path.working_directory_id, + &source_actor, + &request.reason, + ) + .map_err(ApiError::from) + }) + .await } fn external_workdir_expires_at( @@ -23052,9 +25177,18 @@ fn schedule_external_workdir_expiry( } Err(error) => { tracing::error!(grant_id = %task_grant_id, error = ?error, "Backend External Workdir expiry cleanup requires retry"); + let stage_api = task_api.clone(); + let stage_grant_id = task_grant_id.clone(); let retryable = task_api .store - .get_external_workdir_grant(&task_api.config.workspace_id, &task_grant_id) + .db_executor() + .run(move || { + stage_api.store.get_external_workdir_grant( + &stage_api.config.workspace_id, + &stage_grant_id, + ) + }) + .await .map_or(true, |grant| grant.is_some()); if !retryable { break; @@ -23119,6 +25253,18 @@ async fn stop_external_workdir_provider( async fn fence_external_workdir_grant_expired( api: &WorkspaceApi, grant_id: &str, +) -> ApiResult { + let stage_api = api.clone(); + let grant_id = grant_id.to_string(); + api.store + .db_executor() + .run(move || fence_external_workdir_grant_expired_stage(&stage_api, &grant_id)) + .await +} + +fn fence_external_workdir_grant_expired_stage( + api: &WorkspaceApi, + grant_id: &str, ) -> ApiResult { loop { let mut grant = api @@ -23188,6 +25334,8 @@ async fn scoped_create_external_workdir_grant( Extension(actor): Extension, Json(request): Json, ) -> ApiResult<(StatusCode, Json)> { + api.store.db_executor().run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; let display_name = request.display_name.trim(); if display_name.is_empty() @@ -23257,6 +25405,8 @@ async fn scoped_create_external_workdir_grant( StatusCode::CREATED, Json(external_workdir_grant_response(&grant)?), )) + + }).await } fn external_workdir_grant_response( @@ -23281,16 +25431,21 @@ async fn scoped_external_workdir_provider_ws( AxumPath((workspace_id, grant_id)): AxumPath<(String, String)>, ws: WebSocketUpgrade, ) -> ApiResult { - validate_workspace_scope(&api, &workspace_id)?; - let mut grant = api - .store - .get_external_workdir_grant(&workspace_id, &grant_id)? - .ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?; - if grant.created_by != actor.account_id { - return Err(Error::WorkspacePermissionDenied( - "External Workdir provider connection must use the grant creator's authenticated client".to_string(), - ).into()); - } + let stage_api = api.clone(); + let stage_workspace_id = workspace_id.clone(); + let stage_grant_id = grant_id.clone(); + let mut grant = api.store.db_executor().run(move || -> ApiResult<_> { + validate_workspace_scope(&stage_api, &stage_workspace_id)?; + let grant = stage_api.store + .get_external_workdir_grant(&stage_workspace_id, &stage_grant_id)? + .ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?; + if grant.created_by != actor.account_id { + return Err(Error::WorkspacePermissionDenied( + "External Workdir provider connection must use the grant creator's authenticated client".to_string(), + ).into()); + } + Ok(grant) + }).await?; let expired = external_workdir_grant_is_expired(&grant)?; if expired { grant = fence_external_workdir_grant_expired(&api, &grant_id).await?; @@ -23437,81 +25592,102 @@ async fn serve_external_workdir_provider( .await; return; } - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); - if !api + let stage_api = api.clone(); + let stage_grant = initial_grant.clone(); + let registration_generation = registration.generation; + let registration_capabilities = registration.capabilities; + let activation = api .store - .activate_external_workdir_grant( - &initial_grant.workspace_id, - &initial_grant.grant_id, - &initial_grant.provider_instance_id, - registration.generation, - &now, - ) - .unwrap_or(false) - { - let _ = socket.close().await; - return; - } - if let Err(error) = api.store.record_external_workdir_audit( - &initial_grant.workspace_id, - "account", - &initial_grant.created_by, - "external_workdir_provider_connected", - &initial_grant.grant_id, - None, - "online", - &format!( - "workdir_id={} generation={}", - initial_grant.workdir_id, registration.generation - ), - &now, - ) { - let _ = api.store.update_external_workdir_grant_state( - &initial_grant.workspace_id, - &initial_grant.grant_id, - &initial_grant.provider_instance_id, - registration.generation, - "offline", - &now, - ); - tracing::error!( - grant_id = %initial_grant.grant_id, - generation = registration.generation, - %error, - "failed to audit External Workdir provider activation" - ); - let _ = socket.close().await; - return; - } - let expires_at = match external_workdir_expires_at(&initial_grant) { - Ok(value) => value, - Err(_) => { + .db_executor() + .run(move || -> Result<_> { + let api = stage_api; + let initial_grant = stage_grant; + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); + if !api + .store + .activate_external_workdir_grant( + &initial_grant.workspace_id, + &initial_grant.grant_id, + &initial_grant.provider_instance_id, + registration_generation, + &now, + ) + .unwrap_or(false) + { + return Ok(None); + } + if let Err(error) = api.store.record_external_workdir_audit( + &initial_grant.workspace_id, + "account", + &initial_grant.created_by, + "external_workdir_provider_connected", + &initial_grant.grant_id, + None, + "online", + &format!( + "workdir_id={} generation={}", + initial_grant.workdir_id, registration_generation + ), + &now, + ) { + let _ = api.store.update_external_workdir_grant_state( + &initial_grant.workspace_id, + &initial_grant.grant_id, + &initial_grant.provider_instance_id, + registration_generation, + "offline", + &now, + ); + tracing::error!( + grant_id = %initial_grant.grant_id, + generation = registration_generation, + %error, + "failed to audit External Workdir provider activation" + ); + return Ok(None); + } + let expires_at = match external_workdir_expires_at(&initial_grant) { + Ok(value) => value, + Err(_) => return Ok(None), + }; + let (sender, commands) = tokio::sync::mpsc::channel(32); + let connection = Arc::new(ExternalProviderConnection { + grant_id: initial_grant.grant_id.clone(), + workdir_id: initial_grant.workdir_id.clone(), + provider_instance_id: initial_grant.provider_instance_id.clone(), + generation: registration_generation, + expires_at, + capabilities: registration_capabilities, + admission: Arc::new(tokio::sync::Semaphore::new(16)), + shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + sender, + }); + // Publish with the accepted activation stage, never in its cancellable waiter. + if api + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .connected(connection.clone()) + .is_some() + { + return Ok(None); + } + Ok(Some((connection, commands))) + }) + .await; + let (connection, mut commands) = match activation { + Ok(Some(activated)) => activated, + Ok(None) => { + let _ = socket.close().await; + return; + } + Err(error) => { + tracing::error!(grant_id = %initial_grant.grant_id, %error, + "External Workdir provider activation stage failed"); let _ = socket.close().await; return; } }; - let (sender, mut commands) = tokio::sync::mpsc::channel(32); - let connection = Arc::new(ExternalProviderConnection { - grant_id: initial_grant.grant_id.clone(), - workdir_id: initial_grant.workdir_id.clone(), - provider_instance_id: initial_grant.provider_instance_id.clone(), - generation: registration.generation, - expires_at, - capabilities: registration.capabilities, - admission: Arc::new(tokio::sync::Semaphore::new(16)), - shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)), - sender, - }); - if api - .external_workdir_providers - .lock() - .expect("External Workdir provider registry poisoned") - .connected(connection.clone()) - .is_some() - { - let _ = socket.close().await; - return; - } let registered = ExternalWorkdirServerFrame::current(ExternalWorkdirServerMessage::Registered { generation: connection.generation, @@ -23519,19 +25695,36 @@ async fn serve_external_workdir_provider( max_in_flight_operations: 16, }); if !send_external_workdir_server_frame(&mut socket, ®istered).await { - let _ = api - .external_workdir_providers - .lock() - .expect("External Workdir provider registry poisoned") - .disconnect(&connection.grant_id, connection.generation); - if let Err(error) = api.store.update_external_workdir_grant_state( - &initial_grant.workspace_id, - &initial_grant.grant_id, - &connection.provider_instance_id, - connection.generation, - "offline", - &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), - ) { + let stage_api = api.clone(); + let stage_grant = initial_grant.clone(); + let stage_connection = connection.clone(); + if let Err(error) = api + .store + .db_executor() + .run(move || { + stage_api + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .disconnect(&stage_connection.grant_id, stage_connection.generation); + stage_api.store.update_external_workdir_grant_state( + &stage_grant.workspace_id, + &stage_grant.grant_id, + &stage_connection.provider_instance_id, + stage_connection.generation, + "offline", + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) + }) + .await + { + // Rejected admission must not leave a dead socket discoverable. + // The generation-bound in-memory fence is safe even for an unknown + // DB outcome; it neither guesses nor retries the durable mutation. + api.external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .disconnect(&connection.grant_id, connection.generation); tracing::error!( grant_id = %initial_grant.grant_id, generation = connection.generation, @@ -23681,55 +25874,81 @@ async fn serve_external_workdir_provider( } }; pending.clear(); - let _ = api - .external_workdir_providers - .lock() - .expect("External Workdir provider registry poisoned") - .disconnect(&connection.grant_id, connection.generation); - let state_updated = match api.store.update_external_workdir_grant_state( - &initial_grant.workspace_id, - &initial_grant.grant_id, - &connection.provider_instance_id, - connection.generation, - terminal_status, - &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), - ) { + let stage_api = api.clone(); + let stage_grant = initial_grant.clone(); + let stage_connection = connection.clone(); + let terminal_update = api + .store + .db_executor() + .run(move || -> Result<_> { + let api = stage_api; + let initial_grant = stage_grant; + let connection = stage_connection; + let _ = api + .external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .disconnect(&connection.grant_id, connection.generation); + let state_updated = match api.store.update_external_workdir_grant_state( + &initial_grant.workspace_id, + &initial_grant.grant_id, + &connection.provider_instance_id, + connection.generation, + terminal_status, + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) { + Ok(updated) => updated, + Err(error) => { + tracing::error!( + grant_id = %initial_grant.grant_id, + generation = connection.generation, + status = terminal_status, + %error, + "failed to persist External Workdir provider terminal state" + ); + false + } + }; + if state_updated + && let Err(error) = api.store.record_external_workdir_audit( + &initial_grant.workspace_id, + "account", + &initial_grant.created_by, + "external_workdir_provider_disconnected", + &initial_grant.grant_id, + None, + terminal_status, + &format!( + "workdir_id={} generation={}", + initial_grant.workdir_id, connection.generation + ), + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) + { + tracing::error!( + grant_id = %initial_grant.grant_id, + generation = connection.generation, + status = terminal_status, + %error, + "failed to audit External Workdir provider terminal state" + ); + } + Ok(state_updated) + }) + .await; + let state_updated = match terminal_update { Ok(updated) => updated, Err(error) => { - tracing::error!( - grant_id = %initial_grant.grant_id, - generation = connection.generation, - status = terminal_status, - %error, - "failed to persist External Workdir provider terminal state" - ); + api.external_workdir_providers + .lock() + .expect("External Workdir provider registry poisoned") + .disconnect(&connection.grant_id, connection.generation); + tracing::error!(grant_id = %initial_grant.grant_id, + generation = connection.generation, status = terminal_status, %error, + "External Workdir provider terminal stage failed"); false } }; - if state_updated - && let Err(error) = api.store.record_external_workdir_audit( - &initial_grant.workspace_id, - "account", - &initial_grant.created_by, - "external_workdir_provider_disconnected", - &initial_grant.grant_id, - None, - terminal_status, - &format!( - "workdir_id={} generation={}", - initial_grant.workdir_id, connection.generation - ), - &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), - ) - { - tracing::error!( - grant_id = %initial_grant.grant_id, - generation = connection.generation, - status = terminal_status, - %error, - "failed to audit External Workdir provider terminal state" - ); - } if state_updated && matches!(terminal_status, "expired" | "revoked") && let Err(error) = @@ -23749,17 +25968,27 @@ async fn scoped_get_external_workdir_grant( Extension(actor): Extension, AxumPath((workspace_id, grant_id)): AxumPath<(String, String)>, ) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; + let stage_api = api.clone(); + let stage_workspace_id = workspace_id.clone(); + let stage_grant_id = grant_id.clone(); let mut grant = api .store - .get_external_workdir_grant(&workspace_id, &grant_id)? - .ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?; - if grant.created_by != actor.account_id { - return Err(Error::WorkspacePermissionDenied( - "External Workdir grant inspection requires the grant creator".to_string(), - ) - .into()); - } + .db_executor() + .run(move || -> ApiResult<_> { + validate_workspace_scope(&stage_api, &stage_workspace_id)?; + let grant = stage_api + .store + .get_external_workdir_grant(&stage_workspace_id, &stage_grant_id)? + .ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?; + if grant.created_by != actor.account_id { + return Err(Error::WorkspacePermissionDenied( + "External Workdir grant inspection requires the grant creator".to_string(), + ) + .into()); + } + Ok(grant) + }) + .await?; let expired = !matches!(grant.status.as_str(), "revoked" | "expired") && external_workdir_grant_is_expired(&grant)?; if expired { @@ -23786,44 +26015,57 @@ async fn scoped_revoke_external_workdir_grant( Extension(actor): Extension, AxumPath((workspace_id, grant_id)): AxumPath<(String, String)>, ) -> ApiResult> { - validate_workspace_scope(&api, &workspace_id)?; - let mut grant = api + let stage_api = api.clone(); + let stage_workspace_id = workspace_id.clone(); + let stage_grant_id = grant_id.clone(); + let grant = api .store - .get_external_workdir_grant(&workspace_id, &grant_id)? - .ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?; - if grant.created_by != actor.account_id { - return Err(Error::WorkspacePermissionDenied( - "External Workdir revoke requires the grant creator".to_string(), - ) - .into()); - } - let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); - if !matches!(grant.status.as_str(), "revoked" | "expired") { - api.store.update_external_workdir_grant_state( - &workspace_id, - &grant_id, - &grant.provider_instance_id, - grant.generation, - "revoked", - &updated_at, - )?; - grant.status = "revoked".to_string(); - grant.updated_at = updated_at; - api.store.record_external_workdir_audit( - &workspace_id, - "account", - &actor.account_id, - "external_workdir_grant_revoked", - &grant_id, - None, - "revoked", - &format!( - "workdir_id={} generation={}", - grant.workdir_id, grant.generation - ), - &grant.updated_at, - )?; - } + .db_executor() + .run(move || -> ApiResult<_> { + let api = stage_api; + let workspace_id = stage_workspace_id; + let grant_id = stage_grant_id; + validate_workspace_scope(&api, &workspace_id)?; + let mut grant = api + .store + .get_external_workdir_grant(&workspace_id, &grant_id)? + .ok_or_else(|| Error::InvalidInput("Unknown External Workdir grant".to_string()))?; + if grant.created_by != actor.account_id { + return Err(Error::WorkspacePermissionDenied( + "External Workdir revoke requires the grant creator".to_string(), + ) + .into()); + } + let updated_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); + if !matches!(grant.status.as_str(), "revoked" | "expired") { + api.store.update_external_workdir_grant_state( + &workspace_id, + &grant_id, + &grant.provider_instance_id, + grant.generation, + "revoked", + &updated_at, + )?; + grant.status = "revoked".to_string(); + grant.updated_at = updated_at; + api.store.record_external_workdir_audit( + &workspace_id, + "account", + &actor.account_id, + "external_workdir_grant_revoked", + &grant_id, + None, + "revoked", + &format!( + "workdir_id={} generation={}", + grant.workdir_id, grant.generation + ), + &grant.updated_at, + )?; + } + Ok(grant) + }) + .await?; // Cleanup is deliberately retried for terminal grants. A prior revoke may // have committed its fence before a Runtime/session projection failure. let cleanup = cleanup_external_workdir_attachments(&api, &grant.workdir_id).await; @@ -23848,70 +26090,88 @@ async fn cleanup_external_workdir_attachments( async fn cleanup_external_workdir_attachments_with_lock( api: &WorkspaceApi, workdir_id: &str, - already_locked_worker: Option<&RuntimeWorkerRef>, + already_locked_worker: Option<(&RuntimeWorkerRef, &Arc>)>, ) -> ApiResult<()> { - let workdir = api + let stage_api = api.clone(); + let stage_workdir_id = workdir_id.to_string(); + let stage_guard = already_locked_worker.map(|(_, guard)| guard.clone()); + let grant_id = api .store - .get_workdir_registry(&api.config.workspace_id, workdir_id)? - .ok_or_else(|| Error::InvalidInput("Unknown External Workdir".to_string()))?; - let grant_id = workdir.source.external_grant_id().ok_or_else(|| { - Error::InvalidInput("Workdir is not backed by an External grant".to_string()) - })?; - let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); - api.store.update_external_workdir_cleanup_state( - &api.config.workspace_id, - grant_id, - "pending", - None, - &now, - )?; - - match release_external_workdir_attachments(api, workdir_id, already_locked_worker).await { - Ok(()) => { - let completed_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); - api.store.update_external_workdir_cleanup_state( - &api.config.workspace_id, - grant_id, - "completed", + .db_executor() + .run(move || -> Result<_> { + let _session_guard = stage_guard; + let workdir = stage_api + .store + .get_workdir_registry(&stage_api.config.workspace_id, &stage_workdir_id)? + .ok_or_else(|| Error::InvalidInput("Unknown External Workdir".to_string()))?; + let grant_id = workdir + .source + .external_grant_id() + .ok_or_else(|| { + Error::InvalidInput("Workdir is not backed by an External grant".to_string()) + })? + .to_string(); + stage_api.store.update_external_workdir_cleanup_state( + &stage_api.config.workspace_id, + &grant_id, + "pending", None, - &completed_at, + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), )?; - api.store.record_external_workdir_audit( - &api.config.workspace_id, + Ok(grant_id) + }) + .await?; + + let release = + release_external_workdir_attachments(api, workdir_id, already_locked_worker).await; + let stage_api = api.clone(); + let stage_workdir_id = workdir_id.to_string(); + let succeeded = release.is_ok(); + let stage_guard = already_locked_worker.map(|(_, guard)| guard.clone()); + api.store + .db_executor() + .run(move || -> Result<()> { + let _session_guard = stage_guard; + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); + let (state, category, event, outcome, detail) = if succeeded { + ( + "completed", + None, + "external_workdir_cleanup_completed", + "completed", + format!("workdir_id={stage_workdir_id}"), + ) + } else { + ( + "retry_required", + Some("attachment_release_failed"), + "external_workdir_cleanup_attention_required", + "attention_required", + format!("workdir_id={stage_workdir_id} category=attachment_release_failed"), + ) + }; + stage_api.store.update_external_workdir_cleanup_state( + &stage_api.config.workspace_id, + &grant_id, + state, + category, + &now, + )?; + stage_api.store.record_external_workdir_audit( + &stage_api.config.workspace_id, "system", "workspace-backend", - "external_workdir_cleanup_completed", - grant_id, + event, + &grant_id, None, - "completed", - &format!("workdir_id={workdir_id}"), - &completed_at, + outcome, + &detail, + &now, )?; Ok(()) - } - Err(error) => { - let failed_at = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); - api.store.update_external_workdir_cleanup_state( - &api.config.workspace_id, - grant_id, - "retry_required", - Some("attachment_release_failed"), - &failed_at, - )?; - api.store.record_external_workdir_audit( - &api.config.workspace_id, - "system", - "workspace-backend", - "external_workdir_cleanup_attention_required", - grant_id, - None, - "attention_required", - &format!("workdir_id={workdir_id} category=attachment_release_failed"), - &failed_at, - )?; - Err(error) - } - } + }) + .await?; + release } #[cfg(test)] @@ -23972,17 +26232,28 @@ async fn release_external_workdir_attachment_locked( api: &WorkspaceApi, workdir_id: &str, link: &WorkerWorkdirLinkRecord, + session_guard: &Arc>, ) -> ApiResult<()> { if link.unlinked_at.is_none() { close_current_worker_attachment_session_locked(api, &link.worker).await?; close_current_worker_command_sessions_for_alias_locked(api, &link.worker, &link.alias) .await?; - api.store.detach_worker_workdir( - &api.config.workspace_id, - &link.worker, - Some(workdir_id), - &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), - )?; + let stage_api = api.clone(); + let stage_worker = link.worker.clone(); + let stage_workdir_id = workdir_id.to_string(); + let stage_guard = session_guard.clone(); + api.store + .db_executor() + .run(move || { + let _session_guard = stage_guard; + stage_api.store.detach_worker_workdir( + &stage_api.config.workspace_id, + &stage_worker, + Some(&stage_workdir_id), + &Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true), + ) + }) + .await?; #[cfg(test)] if take_external_workdir_cleanup_failure(workdir_id) { return Err(Error::RuntimeOperationFailed { @@ -23997,9 +26268,10 @@ async fn release_external_workdir_attachment_locked( // have committed detach before Runtime persistence or projection failed. #[cfg(test)] record_external_workdir_cleanup_reconciliation(workdir_id); - sync_runtime_worker_workdir_attachments(api, &link.worker)?; + sync_runtime_worker_workdir_attachments_locked(api, &link.worker, session_guard).await?; api.worker_projection - .refresh(&link.worker) + .refresh_async(&link.worker) + .await .map_err(ApiError::from)?; Ok(()) } @@ -24007,23 +26279,34 @@ async fn release_external_workdir_attachment_locked( async fn release_external_workdir_attachments( api: &WorkspaceApi, workdir_id: &str, - already_locked_worker: Option<&RuntimeWorkerRef>, + already_locked_worker: Option<(&RuntimeWorkerRef, &Arc>)>, ) -> ApiResult<()> { + let stage_api = api.clone(); + let stage_workdir_id = workdir_id.to_string(); let Some(link) = api .store - .latest_workdir_worker_link(&api.config.workspace_id, workdir_id)? + .db_executor() + .run(move || { + stage_api + .store + .latest_workdir_worker_link(&stage_api.config.workspace_id, &stage_workdir_id) + }) + .await? else { return Ok(()); }; // The newest link is the only possible active attachment and remains the // durable reconciliation target if detach committed before Runtime update. // Missing/ambiguous Runtime state remains a retryable cleanup failure. - if already_locked_worker == Some(&link.worker) { - release_external_workdir_attachment_locked(api, workdir_id, &link).await?; + if let Some((worker, guard)) = + already_locked_worker.filter(|(worker, _)| *worker == &link.worker) + { + debug_assert_eq!(worker, &link.worker); + release_external_workdir_attachment_locked(api, workdir_id, &link, guard).await?; } else { let lock = current_worker_session_lock(api, &link.worker); - let _guard = lock.lock().await; - release_external_workdir_attachment_locked(api, workdir_id, &link).await?; + let guard = Arc::new(lock.lock_owned().await); + release_external_workdir_attachment_locked(api, workdir_id, &link, &guard).await?; } Ok(()) } @@ -24032,14 +26315,19 @@ async fn scoped_list_working_directories( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let (items, diagnostics) = working_directory_summaries(&api)?; - let items = items.into_iter().map(Into::into).collect(); - Ok(Json(BrowserWorkingDirectoryListResponse { - workspace_id: api.config.workspace_id.clone(), - items, - diagnostics: working_directory_diagnostics(diagnostics), - })) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let (items, diagnostics) = working_directory_summaries(&api)?; + let items = items.into_iter().map(Into::into).collect(); + Ok(Json(BrowserWorkingDirectoryListResponse { + workspace_id: api.config.workspace_id.clone(), + items, + diagnostics: working_directory_diagnostics(diagnostics), + })) + }) + .await } async fn scoped_create_working_directory( @@ -24054,40 +26342,47 @@ async fn scoped_working_directory_detail( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let record = api - .store - .get_workdir_registry(&api.config.workspace_id, &path.working_directory_id)? - .ok_or_else(|| Error::RuntimeOperationFailed { - runtime_id: "workspace-backend".to_string(), - code: "working_directory_not_found".to_string(), - message: format!("Unknown Workdir `{}`", path.working_directory_id), - })?; - if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &record.source { - let grant = api - .store - .get_workspace_config_grant(&api.config.workspace_id, grant_id)? - .ok_or_else(|| { - Error::WorkspacePermissionDenied("Workspace config grant unavailable".into()) - })?; - return Ok(Json(BrowserWorkingDirectoryDetailResponse { - workspace_id: api.config.workspace_id.clone(), - runtime_id: None, - item: workspace_config::summary(&grant, &record), - diagnostics: vec![], - })); - } - if let Some(runtime_id) = record.source.runtime_id().map(str::to_string) { - working_directory_detail_for_runtime(api, &runtime_id, &path.working_directory_id) - } else { - let item = projected_workdir_summary_from_record(&api, &record)?; - Ok(Json(BrowserWorkingDirectoryDetailResponse { - workspace_id: api.config.workspace_id.clone(), - runtime_id: None, - item: item.into(), - diagnostics: Vec::new(), - })) - } + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let record = api + .store + .get_workdir_registry(&api.config.workspace_id, &path.working_directory_id)? + .ok_or_else(|| Error::RuntimeOperationFailed { + runtime_id: "workspace-backend".to_string(), + code: "working_directory_not_found".to_string(), + message: format!("Unknown Workdir `{}`", path.working_directory_id), + })?; + if let WorkdirRegistrySource::WorkspaceConfig { grant_id } = &record.source { + let grant = api + .store + .get_workspace_config_grant(&api.config.workspace_id, grant_id)? + .ok_or_else(|| { + Error::WorkspacePermissionDenied( + "Workspace config grant unavailable".into(), + ) + })?; + return Ok(Json(BrowserWorkingDirectoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: None, + item: workspace_config::summary(&grant, &record), + diagnostics: vec![], + })); + } + if let Some(runtime_id) = record.source.runtime_id().map(str::to_string) { + working_directory_detail_for_runtime(api, &runtime_id, &path.working_directory_id) + } else { + let item = projected_workdir_summary_from_record(&api, &record)?; + Ok(Json(BrowserWorkingDirectoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + runtime_id: None, + item: item.into(), + diagnostics: Vec::new(), + })) + } + }) + .await } async fn cleanup_working_directory( @@ -24097,13 +26392,18 @@ async fn cleanup_working_directory( request: WorkingDirectoryRemovalRequest, ) -> ApiResult { validate_workspace_scope(&api, &path.workspace_id)?; - execute_workdir_removal( - &api, - &path.working_directory_id, - &source_actor, - &request.reason, - ) - .map_err(ApiError::from) + api.host_executors + .host()? + .run(move || { + execute_workdir_removal( + &api, + &path.working_directory_id, + &source_actor, + &request.reason, + ) + .map_err(ApiError::from) + }) + .await } fn registered_workdir_runtime_id( @@ -24175,12 +26475,49 @@ fn finish_rejected_workdir_create_operation( Ok(failure_code) } -async fn create_workspace_working_directory( +async fn fail_workdir_create_stage( + api: &WorkspaceApi, + reserved: &WorkdirCreateOperationRecord, + request_fingerprint: &str, + failure: &str, + remove_registry: bool, +) -> Result { + let stage_api = api.clone(); + let reserved = reserved.clone(); + let request_fingerprint = request_fingerprint.to_string(); + let failure = failure.to_string(); + api.store + .db_executor() + .run(move || { + if remove_registry { + let _ = stage_api.store.delete_workdir_registry( + &reserved.workspace_id, + &reserved.working_directory_id, + ); + } + stage_api.config_store.finish_workdir_create_operation( + &reserved.workspace_id, + &reserved.operation_id, + &request_fingerprint, + false, + Some(&failure), + &now_registry_timestamp(), + ) + }) + .await +} + +fn prepare_workspace_workdir_create( api: &WorkspaceApi, workspace_id: &str, route_runtime_id: Option<&str>, mut request: BrowserWorkingDirectoryCreateRequest, -) -> ApiResult<(StatusCode, Json)> { +) -> ApiResult<( + WorkingDirectoryRequest, + String, + String, + WorkdirCreateOperationRecord, +)> { validate_workspace_scope(api, workspace_id)?; request.display_name = request .display_name @@ -24339,55 +26676,111 @@ async fn create_workspace_working_directory( })? }; - if reserved.state == "succeeded" { - return working_directory_detail_for_runtime( - api.clone(), - &reserved.resolved_runtime_id, - &reserved.working_directory_id, - ) - .map(|Json(response)| { - ( - StatusCode::OK, - Json(BrowserWorkingDirectoryCreateResponse { - workspace_id: response.workspace_id, - runtime_id: response - .runtime_id - .expect("Repository Workdir detail has Runtime"), - item: response.item, - diagnostics: response.diagnostics, - http_status: StatusCode::OK.as_u16(), - }), + Ok(( + working_directory_request, + operation_id, + request_fingerprint, + reserved, + )) +} + +async fn create_workspace_working_directory( + api: &WorkspaceApi, + workspace_id: &str, + route_runtime_id: Option<&str>, + request: BrowserWorkingDirectoryCreateRequest, +) -> ApiResult<(StatusCode, Json)> { + let stage_api = api.clone(); + let stage_workspace_id = workspace_id.to_string(); + let route_runtime_id = route_runtime_id.map(str::to_string); + let (mut working_directory_request, operation_id, request_fingerprint, reserved) = api + .store + .db_executor() + .run(move || { + prepare_workspace_workdir_create( + &stage_api, + &stage_workspace_id, + route_runtime_id.as_deref(), + request, ) - }); + }) + .await?; + + if reserved.state == "succeeded" { + let stage_api = api.clone(); + return api + .host_executors + .host()? + .run(move || { + working_directory_detail_for_runtime( + stage_api, + &reserved.resolved_runtime_id, + &reserved.working_directory_id, + ) + .map(|Json(response)| { + ( + StatusCode::OK, + Json(BrowserWorkingDirectoryCreateResponse { + workspace_id: response.workspace_id, + runtime_id: response + .runtime_id + .expect("Repository Workdir detail has Runtime"), + item: response.item, + diagnostics: response.diagnostics, + http_status: StatusCode::OK.as_u16(), + }), + ) + }) + }) + .await; } let reserved = if reserved.state == "failed" { - api.config_store.begin_failed_workdir_create_retry( - workspace_id, - &operation_id, - &request_fingerprint, - &now_registry_timestamp(), - )? + let stage_api = api.clone(); + let stage_workspace_id = workspace_id.to_string(); + let stage_operation_id = operation_id.clone(); + let stage_fingerprint = request_fingerprint.clone(); + api.store + .db_executor() + .run(move || { + stage_api.config_store.begin_failed_workdir_create_retry( + &stage_workspace_id, + &stage_operation_id, + &stage_fingerprint, + &now_registry_timestamp(), + ) + }) + .await? } else { reserved }; + let stage_api = api.clone(); + let stage_runtime_id = reserved.resolved_runtime_id.clone(); let runtime = match api - .runtime - .list_runtimes(usize::MAX) - .items - .into_iter() - .find(|runtime| runtime.runtime_id == reserved.resolved_runtime_id) + .host_executors + .host()? + .run(move || { + Ok::<_, Error>( + stage_api + .runtime + .list_runtimes(usize::MAX) + .items + .into_iter() + .find(|runtime| runtime.runtime_id == stage_runtime_id), + ) + }) + .await? { Some(runtime) => runtime, None => { - api.config_store.finish_workdir_create_operation( - workspace_id, - &operation_id, + fail_workdir_create_stage( + api, + &reserved, &request_fingerprint, + "runtime_unavailable", false, - Some("runtime_unavailable"), - &now_registry_timestamp(), - )?; + ) + .await?; return Err(ApiError::with_diagnostics( Error::UnknownRuntime(reserved.resolved_runtime_id), vec![RuntimeDiagnostic { @@ -24399,14 +26792,14 @@ async fn create_workspace_working_directory( } }; if runtime.kind == "remote_http" && runtime.status != "connected" { - api.config_store.finish_workdir_create_operation( - workspace_id, - &operation_id, + fail_workdir_create_stage( + api, + &reserved, &request_fingerprint, + "runtime_unavailable", false, - Some("runtime_unavailable"), - &now_registry_timestamp(), - )?; + ) + .await?; return Err(ApiError::with_diagnostics( Error::RuntimeOperationFailed { runtime_id: reserved.resolved_runtime_id, @@ -24421,38 +26814,52 @@ async fn create_workspace_working_directory( )); } - if let Err(error) = authorize_repository_materialization_operation( - api, - &reserved, - &request_fingerprint, - &mut working_directory_request, - ) { - api.config_store.finish_workdir_create_operation( - workspace_id, - &operation_id, + let stage_api = api.clone(); + let stage_reserved = reserved.clone(); + let stage_fingerprint = request_fingerprint.clone(); + let (authorization, mut working_directory_request) = api + .store + .db_executor() + .run(move || { + let authorization = authorize_repository_materialization_operation( + &stage_api, + &stage_reserved, + &stage_fingerprint, + &mut working_directory_request, + ); + Ok::<_, Error>((authorization, working_directory_request)) + }) + .await?; + if let Err(error) = authorization { + fail_workdir_create_stage( + api, + &reserved, &request_fingerprint, + "working_directory_remote_repository_access_required", false, - Some("working_directory_remote_repository_access_required"), - &now_registry_timestamp(), - )?; + ) + .await?; return Err(error); } working_directory_request.backend_workdir_id = Some(reserved.working_directory_id.clone()); - let existing = match api.runtime.working_directory( - &reserved.resolved_runtime_id, - &reserved.working_directory_id, - ) { + let stage_api = api.clone(); + let stage_reserved = reserved.clone(); + let existing = match api + .host_executors + .host()? + .run(move || { + Ok::<_, Error>(stage_api.runtime.working_directory( + &stage_reserved.resolved_runtime_id, + &stage_reserved.working_directory_id, + )) + }) + .await? + { Ok(existing) => existing, Err(error) => { let failure_code = workdir_runtime_failure_code(&error); - api.config_store.finish_workdir_create_operation( - workspace_id, - &operation_id, - &request_fingerprint, - false, - Some(&failure_code), - &now_registry_timestamp(), - )?; + fail_workdir_create_stage(api, &reserved, &request_fingerprint, &failure_code, false) + .await?; return Err(ApiError::with_diagnostics( error.into_error(), vec![RuntimeDiagnostic { @@ -24466,24 +26873,31 @@ async fn create_workspace_working_directory( let result = if existing.working_directory.is_some() { existing } else { + let stage_api = api.clone(); + let stage_runtime_id = reserved.resolved_runtime_id.clone(); match api - .runtime - .create_working_directory(&reserved.resolved_runtime_id, working_directory_request) + .host_executors + .host()? + .run(move || { + Ok::<_, Error>( + stage_api + .runtime + .create_working_directory(&stage_runtime_id, working_directory_request), + ) + }) + .await? { Ok(result) => result, Err(error) => { let failure_code = workdir_runtime_failure_code(&error); - let _ = api - .store - .delete_workdir_registry(workspace_id, &reserved.working_directory_id); - api.config_store.finish_workdir_create_operation( - workspace_id, - &operation_id, + fail_workdir_create_stage( + api, + &reserved, &request_fingerprint, - false, - Some(&failure_code), - &now_registry_timestamp(), - )?; + &failure_code, + true, + ) + .await?; return Err(ApiError::with_diagnostics( error.into_error(), vec![RuntimeDiagnostic { @@ -24496,18 +26910,30 @@ async fn create_workspace_working_directory( } }; let Some(working_directory) = result.working_directory else { - let _ = api - .store - .delete_workdir_registry(workspace_id, &reserved.working_directory_id); let mut diagnostics = result.diagnostics; - let failure_code = finish_rejected_workdir_create_operation( - &api.config_store, - workspace_id, - &operation_id, - &request_fingerprint, - &diagnostics, - &now_registry_timestamp(), - )?; + let stage_api = api.clone(); + let stage_reserved = reserved.clone(); + let stage_fingerprint = request_fingerprint.clone(); + let (failure_code, returned_diagnostics) = api + .store + .db_executor() + .run(move || -> Result<_> { + let _ = stage_api.store.delete_workdir_registry( + &stage_reserved.workspace_id, + &stage_reserved.working_directory_id, + ); + let failure_code = finish_rejected_workdir_create_operation( + &stage_api.config_store, + &stage_reserved.workspace_id, + &stage_reserved.operation_id, + &stage_fingerprint, + &diagnostics, + &now_registry_timestamp(), + )?; + Ok((failure_code, diagnostics)) + }) + .await?; + diagnostics = returned_diagnostics; if !diagnostics.iter().any(|diagnostic| { diagnostic.severity == HostDiagnosticSeverity::Error && diagnostic.code == failure_code }) { @@ -24529,21 +26955,29 @@ async fn create_workspace_working_directory( diagnostics, )); }; - let record = workdir_record_from_summary( - api, - &reserved.resolved_runtime_id, - &working_directory.summary, - ); - api.store.upsert_workdir_registry(&record)?; - api.config_store.finish_workdir_create_operation( - workspace_id, - &operation_id, - &request_fingerprint, - true, - None, - &now_registry_timestamp(), - )?; - let summary = projected_workdir_summary_from_record(api, &record)?; + let stage_api = api.clone(); + let stage_reserved = reserved.clone(); + let summary = api + .store + .db_executor() + .run(move || -> ApiResult<_> { + let record = workdir_record_from_summary( + &stage_api, + &stage_reserved.resolved_runtime_id, + &working_directory.summary, + ); + stage_api.store.upsert_workdir_registry(&record)?; + stage_api.config_store.finish_workdir_create_operation( + &stage_reserved.workspace_id, + &stage_reserved.operation_id, + &request_fingerprint, + true, + None, + &now_registry_timestamp(), + )?; + Ok(projected_workdir_summary_from_record(&stage_api, &record)?) + }) + .await?; Ok(( StatusCode::CREATED, Json(BrowserWorkingDirectoryCreateResponse { @@ -25123,7 +27557,7 @@ async fn set_worker_retention( runtime_worker_id: String, pinned: bool, ) -> ApiResult> { - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &runtime_worker_id)?; + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &runtime_worker_id).await?; Ok(Json( worker .set_pinned(&WorkerOperationContext::Backend, pinned) @@ -25387,7 +27821,13 @@ async fn execute_runtime_cleanup_with_context( } context.authorize_workspace(api).await?; - let plan = build_runtime_cleanup_plan(api, runtime_id)?; + let plan_api = api.clone(); + let plan_runtime = runtime_id.to_owned(); + let plan = api + .host_executors + .host()? + .run(move || build_runtime_cleanup_plan(&plan_api, &plan_runtime)) + .await?; // Rebuild the actual candidate set immediately before executing. The digest // binds targets, dependencies and blockers; per-target authority is checked again below. if request.expected_plan_digest != plan.digest { @@ -25410,20 +27850,36 @@ async fn execute_runtime_cleanup_with_context( candidate.runtime_id.clone(), candidate.runtime_worker_id.clone(), ); - if let Some(claim) = api + let authority_api = api.clone(); + let target = worker.clone(); + let (claim, assignment) = api .store - .get_pending_ticket_assignment_operation_for_worker(&api.config.workspace_id, &worker)? - { + .db_executor() + .run(move || -> Result<_> { + Ok(( + authority_api + .store + .get_pending_ticket_assignment_operation_for_worker( + &authority_api.config.workspace_id, + &target, + )?, + authority_api + .store + .get_active_ticket_role_assignment_for_worker( + &authority_api.config.workspace_id, + &target, + )?, + )) + }) + .await?; + if let Some(claim) = claim { return Err(cleanup_api_error( runtime_id, "workspace_cleanup_worker_claim_pending", &format!("Worker has pending Ticket claim {}", claim.operation_id), )); } - if let Some(assignment) = api - .store - .get_active_ticket_role_assignment_for_worker(&api.config.workspace_id, &worker)? - { + if let Some(assignment) = assignment { return Err(cleanup_api_error( runtime_id, "workspace_cleanup_worker_assigned", @@ -25449,7 +27905,8 @@ async fn execute_runtime_cleanup_with_context( )); } parse_runtime_worker_id_for_registry(&candidate.runtime_worker_id)?; - WorkspaceWorker::resolve(api, &worker.runtime_id, &worker.worker_id)? + WorkspaceWorker::resolve_async(api, &worker.runtime_id, &worker.worker_id) + .await? .delete_from_plan(context, candidate) .await?; api.workdir_session_locks @@ -25488,12 +27945,21 @@ async fn execute_runtime_cleanup_with_context( }); } CleanupTargetKind::WorkdirCleanCleanup | CleanupTargetKind::WorkdirRecordDelete => { - let removal = execute_workdir_removal( - api, - candidate.workdir_id.as_str(), - &format!("runtime-cleanup:{runtime_id}"), - &format!("cleanup target {}", candidate.target_id), - )?; + let removal_api = api.clone(); + let removal_candidate = candidate.clone(); + let requester = format!("runtime-cleanup:{runtime_id}"); + let removal = api + .host_executors + .host()? + .run(move || { + execute_workdir_removal( + &removal_api, + removal_candidate.workdir_id.as_str(), + &requester, + &format!("cleanup target {}", removal_candidate.target_id), + ) + }) + .await?; let (status, message) = match removal.disposition { WorkingDirectoryRemovalDisposition::Removed => ( "deleted", @@ -25539,7 +28005,13 @@ async fn execute_runtime_cleanup_with_context( )); } - let plan_after = build_runtime_cleanup_plan(api, runtime_id)?; + let plan_api = api.clone(); + let plan_runtime = runtime_id.to_owned(); + let plan_after = api + .host_executors + .host()? + .run(move || build_runtime_cleanup_plan(&plan_api, &plan_runtime)) + .await?; let executed_at = now_registry_timestamp(); Ok(RuntimeCleanupExecutionResponse { workspace_id: api.config.workspace_id.clone(), @@ -25607,30 +28079,44 @@ async fn scoped_update_remote_runtime( .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) - .unwrap_or(&path.runtime_id); - let binding = api - .store - .update_workspace_runtime_binding_metadata( - &path.workspace_id, - &path.runtime_id, - display_name, - endpoint.as_str().trim_end_matches('/'), - &Utc::now().to_rfc3339(), - ) - .await?; - api.runtime_binding_expectations - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert( - (path.workspace_id.clone(), path.runtime_id.clone()), - binding.clone(), - ); - api.runtime_subscription_broker - .unregister_runtime(&path.runtime_id); - api.register_workspace_runtime_binding(binding)?; - Ok(Json( - workspace_runtime_detail(&api, &path.workspace_id, &path.runtime_id).await?, - )) + .unwrap_or(&path.runtime_id) + .to_owned(); + update_remote_runtime_metadata_owned( + api, + path.workspace_id, + path.runtime_id, + display_name, + endpoint.as_str().trim_end_matches('/').to_owned(), + ) + .await +} + +// Post-auth mutation boundary: cancellation cannot split SQL completion from +// publication or discard the accepted workflow's request/resource authority. +async fn update_remote_runtime_metadata_owned( + api: WorkspaceApi, + workspace_id: String, + runtime_id: String, + display_name: String, + endpoint: String, +) -> ApiResult> { + run_host_workflow(api.host_executors.host()?, async move { + let binding = api + .store + .update_workspace_runtime_binding_metadata( + &workspace_id, + &runtime_id, + &display_name, + &endpoint, + &Utc::now().to_rfc3339(), + ) + .await?; + publish_workspace_runtime_binding(&api, binding)?; + Ok(Json( + workspace_runtime_detail_on_host(&api, &workspace_id, &runtime_id).await?, + )) + }) + .await } async fn scoped_reveal_runtime_trust_key( @@ -25759,6 +28245,7 @@ async fn scoped_list_runtime_workers( list_runtime_workers(State(api), AxumPath(path.runtime_id), Query(query)).await } +#[cfg(test)] async fn scoped_create_runtime_worker( State(api): State, AxumPath(path): AxumPath, @@ -25811,7 +28298,9 @@ async fn scoped_restore_runtime_worker_with_context( request: server_api::WorkerRestoreRequest, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - let resolved = WorkspaceWorker::resolve(&api, &path.worker.runtime_id, &path.worker.worker_id)?; + let resolved = + WorkspaceWorker::resolve_async(&api, &path.worker.runtime_id, &path.worker.worker_id) + .await?; let _authorization = resolved.authorize_operation(&context, "restore").await?; let runtime_id = resolved.identity().runtime_id.clone(); let worker_id = resolved.identity().worker_id.clone(); @@ -25844,9 +28333,14 @@ async fn scoped_runtime_cleanup_plan( State(api): State, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let plan = build_runtime_cleanup_plan(&api, path.runtime_id.as_str())?; - Ok(Json(plan)) + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let plan = build_runtime_cleanup_plan(&api, path.runtime_id.as_str())?; + Ok(Json(plan)) + }) + .await } #[cfg(test)] @@ -25891,56 +28385,65 @@ async fn scoped_create_attachment_upload_grant( AxumPath(path): AxumPath, Query(query): Query, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - resolve_workspace_worker_reference(&api, &path.worker.runtime_id, &path.worker.worker_id)?; - let now = attachment_upload_now_ms()?; - let expires_at_ms = now.saturating_add(ATTACHMENT_UPLOAD_GRANT_TTL_MS); - let upload_id = match query.upload_id { - Some(upload_id) => Uuid::parse_str(&upload_id) - .map_err(|_| Error::InvalidInput("attachment upload id must be a UUID".into()))? - .to_string(), - None => Uuid::now_v7().to_string(), - }; - let mut grants = api - .attachment_upload_grants - .lock() - .map_err(|_| Error::Store("attachment upload grant lock is poisoned".into()))?; - grants.retain(|_, grant| grant.expires_at_ms >= now); - if let Some(existing) = grants.get(&upload_id) { - if existing.workspace_id == path.workspace_id - && existing.runtime_id == path.worker.runtime_id - && existing.worker_id == path.worker.worker_id - && existing.account_id == actor.account_id - && existing.file_name == query.file_name - && existing.media_type == query.media_type - { - return Ok(Json(AttachmentUploadGrantResponse { + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + resolve_workspace_worker_reference( + &api, + &path.worker.runtime_id, + &path.worker.worker_id, + )?; + let now = attachment_upload_now_ms()?; + let expires_at_ms = now.saturating_add(ATTACHMENT_UPLOAD_GRANT_TTL_MS); + let upload_id = match query.upload_id { + Some(upload_id) => Uuid::parse_str(&upload_id) + .map_err(|_| Error::InvalidInput("attachment upload id must be a UUID".into()))? + .to_string(), + None => Uuid::now_v7().to_string(), + }; + let mut grants = api + .attachment_upload_grants + .lock() + .map_err(|_| Error::Store("attachment upload grant lock is poisoned".into()))?; + grants.retain(|_, grant| grant.expires_at_ms >= now); + if let Some(existing) = grants.get(&upload_id) { + if existing.workspace_id == path.workspace_id + && existing.runtime_id == path.worker.runtime_id + && existing.worker_id == path.worker.worker_id + && existing.account_id == actor.account_id + && existing.file_name == query.file_name + && existing.media_type == query.media_type + { + return Ok(Json(AttachmentUploadGrantResponse { + upload_id, + expires_at_ms: existing.expires_at_ms, + })); + } + return Err(Error::RepositoryConflict( + "attachment upload id was reused with different intent".into(), + ) + .into()); + } + grants.insert( + upload_id.clone(), + AttachmentUploadGrant { + workspace_id: path.workspace_id, + runtime_id: path.worker.runtime_id, + worker_id: path.worker.worker_id, + account_id: actor.account_id, + file_name: query.file_name, + media_type: query.media_type, + expires_at_ms, + state: AttachmentUploadGrantState::Pending, + }, + ); + Ok(Json(AttachmentUploadGrantResponse { upload_id, - expires_at_ms: existing.expires_at_ms, - })); - } - return Err(Error::RepositoryConflict( - "attachment upload id was reused with different intent".into(), - ) - .into()); - } - grants.insert( - upload_id.clone(), - AttachmentUploadGrant { - workspace_id: path.workspace_id, - runtime_id: path.worker.runtime_id, - worker_id: path.worker.worker_id, - account_id: actor.account_id, - file_name: query.file_name, - media_type: query.media_type, - expires_at_ms, - state: AttachmentUploadGrantState::Pending, - }, - ); - Ok(Json(AttachmentUploadGrantResponse { - upload_id, - expires_at_ms, - })) + expires_at_ms, + })) + }) + .await } async fn upload_runtime_worker_file_contract( @@ -25949,75 +28452,84 @@ async fn upload_runtime_worker_file_contract( path: ScopedAttachmentUploadPath, body: &[u8], ) -> ApiResult { - validate_workspace_scope(&api, &path.workspace_id)?; - let body_sha256 = attachment_body_sha256(body); - let grant = { - let mut grants = api - .attachment_upload_grants - .lock() - .map_err(|_| Error::Store("attachment upload grant lock is poisoned".into()))?; - let grant = grants - .get_mut(&path.upload_id) - .ok_or_else(|| Error::InvalidInput("attachment upload grant is unknown".into()))?; - if let Some(file) = grant - .claim( - &path.workspace_id, - &path.runtime_id, - &path.worker_id, - &actor.account_id, - attachment_upload_now_ms()?, - &body_sha256, - ) - .map_err(ApiError::from)? - { - return Ok(WorkerFileUploadResponse { file }); - } - grant.clone() - }; + let body = body.to_vec(); + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let body_sha256 = attachment_body_sha256(&body); + let grant = { + let mut grants = api + .attachment_upload_grants + .lock() + .map_err(|_| Error::Store("attachment upload grant lock is poisoned".into()))?; + let grant = grants.get_mut(&path.upload_id).ok_or_else(|| { + Error::InvalidInput("attachment upload grant is unknown".into()) + })?; + if let Some(file) = grant + .claim( + &path.workspace_id, + &path.runtime_id, + &path.worker_id, + &actor.account_id, + attachment_upload_now_ms()?, + &body_sha256, + ) + .map_err(ApiError::from)? + { + return Ok(WorkerFileUploadResponse { file }); + } + grant.clone() + }; - let worker = resolve_workspace_worker_reference(&api, &path.runtime_id, &path.worker_id)?; - let upload_context = worker_runtime::UploadedFileUploadContext { - upload_id: path.upload_id.clone(), - principal_id: grant.account_id.clone(), - workspace_id: grant.workspace_id.clone(), - runtime_id: grant.runtime_id.clone(), - worker_id: grant.worker_id.clone(), - }; - let uploaded = api.runtime.upload_worker_file( - &worker, - &grant.file_name, - &grant.media_type, - body, - Some(&upload_context), - ); - let mut grants = api - .attachment_upload_grants - .lock() - .map_err(|_| Error::Store("attachment upload grant lock is poisoned".into()))?; - let current = grants - .get_mut(&path.upload_id) - .ok_or_else(|| Error::RepositoryConflict("attachment upload grant disappeared".into()))?; - if matches!(current.state, AttachmentUploadGrantState::Cancelled) { - let uploaded_file = uploaded.ok(); - grants.remove(&path.upload_id); - drop(grants); - if let Some(file) = uploaded_file { - api.runtime - .delete_worker_uploaded_file(&worker, &file.artifact_id) - .map_err(|error| error.into_error())?; - } - return Err(Error::RepositoryConflict("attachment upload was cancelled".into()).into()); - } - match uploaded { - Ok(file) => { - current.state = AttachmentUploadGrantState::Completed(file.clone()); - Ok(WorkerFileUploadResponse { file }) - } - Err(error) => { - current.state = AttachmentUploadGrantState::Pending; - Err(error.into_error().into()) - } - } + let worker = + resolve_workspace_worker_reference(&api, &path.runtime_id, &path.worker_id)?; + let upload_context = worker_runtime::UploadedFileUploadContext { + upload_id: path.upload_id.clone(), + principal_id: grant.account_id.clone(), + workspace_id: grant.workspace_id.clone(), + runtime_id: grant.runtime_id.clone(), + worker_id: grant.worker_id.clone(), + }; + let uploaded = api.runtime.upload_worker_file( + &worker, + &grant.file_name, + &grant.media_type, + &body, + Some(&upload_context), + ); + let mut grants = api + .attachment_upload_grants + .lock() + .map_err(|_| Error::Store("attachment upload grant lock is poisoned".into()))?; + let current = grants.get_mut(&path.upload_id).ok_or_else(|| { + Error::RepositoryConflict("attachment upload grant disappeared".into()) + })?; + if matches!(current.state, AttachmentUploadGrantState::Cancelled) { + let uploaded_file = uploaded.ok(); + grants.remove(&path.upload_id); + drop(grants); + if let Some(file) = uploaded_file { + api.runtime + .delete_worker_uploaded_file(&worker, &file.artifact_id) + .map_err(|error| error.into_error())?; + } + return Err( + Error::RepositoryConflict("attachment upload was cancelled".into()).into(), + ); + } + match uploaded { + Ok(file) => { + current.state = AttachmentUploadGrantState::Completed(file.clone()); + Ok(WorkerFileUploadResponse { file }) + } + Err(error) => { + current.state = AttachmentUploadGrantState::Pending; + Err(error.into_error().into()) + } + } + }) + .await } async fn scoped_cancel_attachment_upload( @@ -26025,49 +28537,55 @@ async fn scoped_cancel_attachment_upload( Extension(actor): Extension, AxumPath(path): AxumPath, ) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let completed = { - let mut grants = api - .attachment_upload_grants - .lock() - .map_err(|_| Error::Store("attachment upload grant lock is poisoned".into()))?; - let grant = grants - .get_mut(&path.upload_id) - .ok_or_else(|| Error::InvalidInput("attachment upload grant is unknown".into()))?; - if grant.workspace_id != path.workspace_id - || grant.runtime_id != path.runtime_id - || grant.worker_id != path.worker_id - || grant.account_id != actor.account_id - { - return Err(Error::WorkspacePermissionDenied( - "attachment upload grant scope mismatch".into(), - ) - .into()); - } - match &grant.state { - AttachmentUploadGrantState::Pending => { - grants.remove(&path.upload_id); - None + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + let completed = { + let mut grants = api + .attachment_upload_grants + .lock() + .map_err(|_| Error::Store("attachment upload grant lock is poisoned".into()))?; + let grant = grants.get_mut(&path.upload_id).ok_or_else(|| { + Error::InvalidInput("attachment upload grant is unknown".into()) + })?; + if grant.workspace_id != path.workspace_id + || grant.runtime_id != path.runtime_id + || grant.worker_id != path.worker_id + || grant.account_id != actor.account_id + { + return Err(Error::WorkspacePermissionDenied( + "attachment upload grant scope mismatch".into(), + ) + .into()); + } + match &grant.state { + AttachmentUploadGrantState::Pending => { + grants.remove(&path.upload_id); + None + } + AttachmentUploadGrantState::Uploading => { + grant.state = AttachmentUploadGrantState::Cancelled; + None + } + AttachmentUploadGrantState::Cancelled => None, + AttachmentUploadGrantState::Completed(file) => { + let file = file.clone(); + grants.remove(&path.upload_id); + Some(file) + } + } + }; + if let Some(file) = completed { + let worker = + resolve_workspace_worker_reference(&api, &path.runtime_id, &path.worker_id)?; + api.runtime + .delete_worker_uploaded_file(&worker, &file.artifact_id) + .map_err(|error| error.into_error())?; } - AttachmentUploadGrantState::Uploading => { - grant.state = AttachmentUploadGrantState::Cancelled; - None - } - AttachmentUploadGrantState::Cancelled => None, - AttachmentUploadGrantState::Completed(file) => { - let file = file.clone(); - grants.remove(&path.upload_id); - Some(file) - } - } - }; - if let Some(file) = completed { - let worker = resolve_workspace_worker_reference(&api, &path.runtime_id, &path.worker_id)?; - api.runtime - .delete_worker_uploaded_file(&worker, &file.artifact_id) - .map_err(|error| error.into_error())?; - } - Ok(Json(AttachmentUploadCancelResponse { cancelled: true })) + Ok(Json(AttachmentUploadCancelResponse { cancelled: true })) + }) + .await } async fn scoped_delete_runtime_worker_uploaded_file( @@ -26144,8 +28662,13 @@ async fn post_auth_bootstrap_user( State(api): State, Json(request): Json, ) -> ApiResult> { - let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; - Ok(Json(AuthUserResponse { user })) + api.store + .db_executor() + .run(move || { + let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; + Ok(Json(AuthUserResponse { user })) + }) + .await } async fn post_passkey_registration_options( @@ -26153,48 +28676,53 @@ async fn post_passkey_registration_options( headers: HeaderMap, Json(request): Json, ) -> ApiResult> { - let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; - let header_origin = request_origin(&headers); - let requested_origin = request - .browser_origin - .as_deref() - .or(header_origin.as_deref()); - let auth = auth_config_for_origin(&api.config, requested_origin)?; - let webauthn = webauthn_for_auth(&auth)?; - let exclude_credentials = passkeys_for_user(&api, &user.user_id)? - .into_iter() - .map(|passkey| passkey.cred_id().clone()) - .collect(); - let user_unique_id = Uuid::now_v7(); - let (public_key, state) = webauthn - .start_passkey_registration( - user_unique_id, - &user.handle, - &user.display_name, - Some(exclude_credentials), - ) - .map_err(|error| auth_error("webauthn_registration_options_failed", &error.to_string()))?; - let challenge_id = new_id("webauthn-registration"); - api.store.put_auth_challenge(&AuthChallengeRecord { - challenge_id: challenge_id.clone(), - ceremony: "passkey_registration".to_string(), - challenge: challenge_id.clone(), - user_id: Some(user.user_id), - rp_id: auth.rp_id, - origin: auth.origin, - state_json: Some( - serde_json::to_string(&state).map_err(|error| { - auth_error("webauthn_state_serialize_failed", &error.to_string()) - })?, - ), - expires_at: rfc3339_after(Duration::minutes(5)), - created_at: crate::auth::now_rfc3339(), - consumed_at: None, - })?; - Ok(Json(PasskeyRegistrationOptionsResponse { - challenge_id, - public_key, - })) + api.store + .db_executor() + .run(move || { + let user = ensure_user_account(&api, &request.handle, request.display_name.as_deref())?; + let header_origin = request_origin(&headers); + let requested_origin = request + .browser_origin + .as_deref() + .or(header_origin.as_deref()); + let auth = auth_config_for_origin(&api.config, requested_origin)?; + let webauthn = webauthn_for_auth(&auth)?; + let exclude_credentials = passkeys_for_user(&api, &user.user_id)? + .into_iter() + .map(|passkey| passkey.cred_id().clone()) + .collect(); + let user_unique_id = Uuid::now_v7(); + let (public_key, state) = webauthn + .start_passkey_registration( + user_unique_id, + &user.handle, + &user.display_name, + Some(exclude_credentials), + ) + .map_err(|error| { + auth_error("webauthn_registration_options_failed", &error.to_string()) + })?; + let challenge_id = new_id("webauthn-registration"); + api.store.put_auth_challenge(&AuthChallengeRecord { + challenge_id: challenge_id.clone(), + ceremony: "passkey_registration".to_string(), + challenge: challenge_id.clone(), + user_id: Some(user.user_id), + rp_id: auth.rp_id, + origin: auth.origin, + state_json: Some(serde_json::to_string(&state).map_err(|error| { + auth_error("webauthn_state_serialize_failed", &error.to_string()) + })?), + expires_at: rfc3339_after(Duration::minutes(5)), + created_at: crate::auth::now_rfc3339(), + consumed_at: None, + })?; + Ok(Json(PasskeyRegistrationOptionsResponse { + challenge_id, + public_key, + })) + }) + .await } /// Consumes a stored WebAuthn ceremony and registers only verified credentials. @@ -26277,50 +28805,53 @@ async fn post_passkey_login_options( headers: HeaderMap, Json(request): Json, ) -> ApiResult> { - let user = match request.handle.as_deref() { - Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?, - None => api.store.any_user()?, - } - .ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?; - let passkeys = passkeys_for_user(&api, &user.user_id)?; - if passkeys.is_empty() { - return Err(auth_error( - "passkey_not_registered", - "user has no registered passkey credentials", - ) - .into()); - } - let header_origin = request_origin(&headers); - let requested_origin = request - .browser_origin - .as_deref() - .or(header_origin.as_deref()); - let auth = auth_config_for_origin(&api.config, requested_origin)?; - let webauthn = webauthn_for_auth(&auth)?; - let (public_key, state) = webauthn - .start_passkey_authentication(&passkeys) - .map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?; - let challenge_id = new_id("webauthn-login"); - api.store.put_auth_challenge(&AuthChallengeRecord { - challenge_id: challenge_id.clone(), - ceremony: "passkey_login".to_string(), - challenge: challenge_id.clone(), - user_id: Some(user.user_id), - rp_id: auth.rp_id, - origin: auth.origin, - state_json: Some( - serde_json::to_string(&state).map_err(|error| { - auth_error("webauthn_state_serialize_failed", &error.to_string()) - })?, - ), - expires_at: rfc3339_after(Duration::minutes(5)), - created_at: crate::auth::now_rfc3339(), - consumed_at: None, - })?; - Ok(Json(PasskeyLoginOptionsResponse { - challenge_id, - public_key, - })) + api.store + .db_executor() + .run(move || { + let user = match request.handle.as_deref() { + Some(handle) => api.store.get_user_by_handle(&normalize_handle(handle)?)?, + None => api.store.any_user()?, + } + .ok_or_else(|| auth_error("unknown_auth_user", "no matching user account exists"))?; + let passkeys = passkeys_for_user(&api, &user.user_id)?; + if passkeys.is_empty() { + return Err(auth_error( + "passkey_not_registered", + "user has no registered passkey credentials", + ) + .into()); + } + let header_origin = request_origin(&headers); + let requested_origin = request + .browser_origin + .as_deref() + .or(header_origin.as_deref()); + let auth = auth_config_for_origin(&api.config, requested_origin)?; + let webauthn = webauthn_for_auth(&auth)?; + let (public_key, state) = webauthn + .start_passkey_authentication(&passkeys) + .map_err(|error| auth_error("webauthn_login_options_failed", &error.to_string()))?; + let challenge_id = new_id("webauthn-login"); + api.store.put_auth_challenge(&AuthChallengeRecord { + challenge_id: challenge_id.clone(), + ceremony: "passkey_login".to_string(), + challenge: challenge_id.clone(), + user_id: Some(user.user_id), + rp_id: auth.rp_id, + origin: auth.origin, + state_json: Some(serde_json::to_string(&state).map_err(|error| { + auth_error("webauthn_state_serialize_failed", &error.to_string()) + })?), + expires_at: rfc3339_after(Duration::minutes(5)), + created_at: crate::auth::now_rfc3339(), + consumed_at: None, + })?; + Ok(Json(PasskeyLoginOptionsResponse { + challenge_id, + public_key, + })) + }) + .await } fn complete_passkey_login( @@ -26438,48 +28969,53 @@ async fn post_device_login_start( State(api): State, Json(request): Json, ) -> ApiResult> { - const MAX_CODE_ALLOCATION_ATTEMPTS: usize = 8; + api.store + .db_executor() + .run(move || { + const MAX_CODE_ALLOCATION_ATTEMPTS: usize = 8; - let auth = auth_public_config(&api.config); - let verification_uri = format!( - "{}/login/device", - auth.public_base_url.trim_end_matches('/') - ); - for _ in 0..MAX_CODE_ALLOCATION_ATTEMPTS { - let device_code = mint_secret("yoi_device"); - let user_code = new_user_code(); - let verification_uri_complete = format!("{verification_uri}?user_code={user_code}"); - let flow = DeviceLoginFlowRecord { - device_code: device_code.clone(), - user_code: user_code.clone(), - verification_uri: verification_uri.clone(), - client_name: request.client_name.clone(), - user_id: None, - api_token_id: None, - issued_access_token: None, - created_at: crate::auth::now_rfc3339(), - expires_at: rfc3339_after(Duration::minutes(10)), - approved_at: None, - consumed_at: None, - }; - if !api.store.try_create_device_login_flow(&flow)? { - continue; - } - return Ok(Json(DeviceLoginStartResponse { - device_code, - user_code, - verification_uri, - verification_uri_complete, - expires_in: 600, - interval: 5, - })); - } + let auth = auth_public_config(&api.config); + let verification_uri = format!( + "{}/login/device", + auth.public_base_url.trim_end_matches('/') + ); + for _ in 0..MAX_CODE_ALLOCATION_ATTEMPTS { + let device_code = mint_secret("yoi_device"); + let user_code = new_user_code(); + let verification_uri_complete = format!("{verification_uri}?user_code={user_code}"); + let flow = DeviceLoginFlowRecord { + device_code: device_code.clone(), + user_code: user_code.clone(), + verification_uri: verification_uri.clone(), + client_name: request.client_name.clone(), + user_id: None, + api_token_id: None, + issued_access_token: None, + created_at: crate::auth::now_rfc3339(), + expires_at: rfc3339_after(Duration::minutes(10)), + approved_at: None, + consumed_at: None, + }; + if !api.store.try_create_device_login_flow(&flow)? { + continue; + } + return Ok(Json(DeviceLoginStartResponse { + device_code, + user_code, + verification_uri, + verification_uri_complete, + expires_in: 600, + interval: 5, + })); + } - Err(auth_error( - "device_login_code_allocation_failed", - "could not allocate a unique device login user code", - ) - .into()) + Err(auth_error( + "device_login_code_allocation_failed", + "could not allocate a unique device login user code", + ) + .into()) + }) + .await } fn approve_device_login( @@ -26551,41 +29087,48 @@ async fn post_device_login_poll( State(api): State, Json(request): Json, ) -> ApiResult> { - let Some(flow) = api - .store - .get_device_login_flow_by_device_code(&request.device_code)? - else { - return Err(auth_error("unknown_device_login", "device login flow does not exist").into()); - }; - if is_expired(&flow.expires_at) { - return Ok(Json(DeviceLoginPollResponse { - status: DeviceLoginPollStatus::Expired, - access_token: None, - token_type: None, - })); - } - if flow.approved_at.is_none() { - return Ok(Json(DeviceLoginPollResponse { - status: DeviceLoginPollStatus::Pending, - access_token: None, - token_type: None, - })); - } - let Some(consumed) = api - .store - .consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())? - else { - return Ok(Json(DeviceLoginPollResponse { - status: DeviceLoginPollStatus::Consumed, - access_token: None, - token_type: None, - })); - }; - Ok(Json(DeviceLoginPollResponse { - status: DeviceLoginPollStatus::Approved, - access_token: consumed.issued_access_token, - token_type: Some(DeviceAccessTokenType::Bearer), - })) + api.store + .db_executor() + .run(move || { + let Some(flow) = api + .store + .get_device_login_flow_by_device_code(&request.device_code)? + else { + return Err( + auth_error("unknown_device_login", "device login flow does not exist").into(), + ); + }; + if is_expired(&flow.expires_at) { + return Ok(Json(DeviceLoginPollResponse { + status: DeviceLoginPollStatus::Expired, + access_token: None, + token_type: None, + })); + } + if flow.approved_at.is_none() { + return Ok(Json(DeviceLoginPollResponse { + status: DeviceLoginPollStatus::Pending, + access_token: None, + token_type: None, + })); + } + let Some(consumed) = api + .store + .consume_device_login_token(&request.device_code, &crate::auth::now_rfc3339())? + else { + return Ok(Json(DeviceLoginPollResponse { + status: DeviceLoginPollStatus::Consumed, + access_token: None, + token_type: None, + })); + }; + Ok(Json(DeviceLoginPollResponse { + status: DeviceLoginPollStatus::Approved, + access_token: consumed.issued_access_token, + token_type: Some(DeviceAccessTokenType::Bearer), + })) + }) + .await } fn logout_browser_session( @@ -26779,7 +29322,7 @@ async fn resolve_actor( 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?) + Ok(resolve_request_actor(api.store.clone(), headers, &cookie_name).await?) } async fn require_actor(api: &ServerAuthApi, headers: &HeaderMap) -> ApiResult { @@ -26871,103 +29414,130 @@ async fn list_tickets( State(api): State, Query(query): Query, ) -> ApiResult> { - let limit = query.limit.unwrap_or(30).clamp(1, 100); - let states = query - .states - .as_deref() - .map(|states| { - states - .split(',') - .filter(|state| !state.is_empty()) - .map(str::to_string) - .collect::>() + api.store + .db_executor() + .run(move || { + let limit = query.limit.unwrap_or(30).clamp(1, 100); + let states = query + .states + .as_deref() + .map(|states| { + states + .split(',') + .filter(|state| !state.is_empty()) + .map(str::to_string) + .collect::>() + }) + .unwrap_or_default(); + let crate::records::TicketSummaryPage { + items, + page, + invalid_records, + record_authority, + } = api + .authority + .list_ticket_page(crate::records::TicketListProjectionRequest { + states, + limit: Some(limit), + cursor: query.cursor, + })?; + Ok(Json(crate::records::TicketListResponse { + workspace_id: api.config.workspace_id, + limit, + items, + page, + invalid_records, + record_authority, + })) }) - .unwrap_or_default(); - let crate::records::TicketSummaryPage { - items, - page, - invalid_records, - record_authority, - } = api - .authority - .list_ticket_page(crate::records::TicketListProjectionRequest { - states, - limit: Some(limit), - cursor: query.cursor, - })?; - Ok(Json(crate::records::TicketListResponse { - workspace_id: api.config.workspace_id, - limit, - items, - page, - invalid_records, - record_authority, - })) + .await } async fn get_ticket( State(api): State, AxumPath(id): AxumPath, ) -> ApiResult> { - Ok(Json(api.authority.ticket(&id)?)) + api.host_executors + .host()? + .run(move || Ok(Json(api.authority.ticket(&id)?))) + .await } async fn list_objectives( State(api): State, Query(query): Query, ) -> ApiResult> { - let limit = query.limit.unwrap_or(api.config.max_records).min(1000); - let ProjectRecordList { - items, - invalid_records, - record_authority, - } = api.authority.list_objectives(limit)?; - Ok(Json(server_api::ObjectiveListResponse { - workspace_id: api.config.workspace_id, - limit, - items, - invalid_records, - record_authority, - })) + api.store + .db_executor() + .run(move || { + let limit = query.limit.unwrap_or(api.config.max_records).min(1000); + let ProjectRecordList { + items, + invalid_records, + record_authority, + } = api.authority.list_objectives(limit)?; + Ok(Json(server_api::ObjectiveListResponse { + workspace_id: api.config.workspace_id, + limit, + items, + invalid_records, + record_authority, + })) + }) + .await } async fn get_objective( State(api): State, AxumPath(id): AxumPath, ) -> ApiResult> { - Ok(Json(api.authority.objective(&id)?)) + api.store + .db_executor() + .run(move || Ok(Json(api.authority.objective(&id)?))) + .await } async fn list_repositories( State(api): State, ) -> ApiResult> { - let RepositoryListProjection { items, diagnostics } = api.live_repository_reader()?.list(); - Ok(Json(RepositoryListResponse { - workspace_id: api.config.workspace_id, - items, - source: "workspace-control-plane".to_string(), - diagnostics, - })) + api.host_executors + .host()? + .run(move || { + let RepositoryListProjection { items, diagnostics } = + api.live_repository_reader()?.list(); + Ok(Json(RepositoryListResponse { + workspace_id: api.config.workspace_id, + items, + source: "workspace-control-plane".to_string(), + diagnostics, + })) + }) + .await } async fn repository_detail( State(api): State, AxumPath(repository_key): AxumPath, ) -> ApiResult> { - server_api::validate_repository_key(&repository_key).map_err(|error| { - ApiError::from(Error::InvalidInput(format!( - "invalid Repository key: {error}" - ))) - })?; - let item = repository_lookup( - api.live_repository_reader()? - .summary_by_key(&repository_key), - )?; - Ok(Json(RepositoryDetailResponse { - workspace_id: api.config.workspace_id.clone(), - item, - source: "workspace-control-plane".to_string(), - })) + api.host_executors + .host()? + .run(move || { + server_api::validate_repository_key(&repository_key).map_err(|error| { + ApiError::from(Error::InvalidInput(format!( + "invalid Repository key: {error}" + ))) + })?; + let item = repository_lookup( + api.live_repository_reader()? + .summary_by_key(&repository_key), + )?; + Ok(Json(RepositoryDetailResponse { + workspace_id: api.config.workspace_id.clone(), + item, + source: "workspace-control-plane".to_string(), + })) + }) + .await } async fn repository_log( @@ -26975,63 +29545,150 @@ async fn repository_log( AxumPath(repository_key): AxumPath, Query(query): Query, ) -> ApiResult> { - server_api::validate_repository_key(&repository_key).map_err(|error| { - ApiError::from(Error::InvalidInput(format!( - "invalid Repository key: {error}" - ))) - })?; - let RepositoryLogRead { - repository_key, - default_selector, - limit, - commits, - diagnostics, - } = repository_lookup( - api.live_repository_reader()? - .recent_log(&repository_key, query.limit), - )?; - Ok(Json(RepositoryLogResponse { - workspace_id: api.config.workspace_id, - repository_key, - default_selector, - limit, - items: commits, - diagnostics, - })) + api.host_executors + .host()? + .run(move || { + server_api::validate_repository_key(&repository_key).map_err(|error| { + ApiError::from(Error::InvalidInput(format!( + "invalid Repository key: {error}" + ))) + })?; + let RepositoryLogRead { + repository_key, + default_selector, + limit, + commits, + diagnostics, + } = repository_lookup( + api.live_repository_reader()? + .recent_log(&repository_key, query.limit), + )?; + Ok(Json(RepositoryLogResponse { + workspace_id: api.config.workspace_id, + repository_key, + default_selector, + limit, + items: commits, + diagnostics, + })) + }) + .await } async fn list_hosts( State(api): State, ) -> ApiResult>> { - let limit = api.config.max_records.min(200); - let runtime_hosts = api.runtime.list_hosts(limit); - Ok(Json(InternalRuntimeListResponse { - workspace_id: api.config.workspace_id, - limit, - items: runtime_hosts.items, - source: "worker_runtime_registry".to_string(), - diagnostics: runtime_hosts.diagnostics, - })) + api.host_executors + .host()? + .run(move || { + let limit = api.config.max_records.min(200); + let runtime_hosts = api.runtime.list_hosts(limit); + Ok(Json(InternalRuntimeListResponse { + workspace_id: api.config.workspace_id, + limit, + items: runtime_hosts.items, + source: "worker_runtime_registry".to_string(), + diagnostics: runtime_hosts.diagnostics, + })) + }) + .await } async fn list_runtimes( State(api): State, ) -> ApiResult> { - let limit = api.config.max_records.min(200); - let runtimes = api.runtime.list_runtimes(limit); - Ok(Json(server_api::RuntimeListResponse { - workspace_id: api.config.workspace_id, - limit, - items: runtimes.items.into_iter().map(Into::into).collect(), - source: "worker_runtime_registry".to_string(), - diagnostics: runtimes.diagnostics.into_iter().map(Into::into).collect(), - })) + api.host_executors + .host()? + .run(move || { + let limit = api.config.max_records.min(200); + let runtimes = api.runtime.list_runtimes(limit); + Ok(Json(server_api::RuntimeListResponse { + workspace_id: api.config.workspace_id, + limit, + items: runtimes.items.into_iter().map(Into::into).collect(), + source: "worker_runtime_registry".to_string(), + diagnostics: runtimes.diagnostics.into_iter().map(Into::into).collect(), + })) + }) + .await } async fn list_workers( State(api): State, ) -> ApiResult> { - workers_response(api).map(Json) + api.store + .db_executor() + .run(move || workers_response(api).map(Json)) + .await +} + +async fn require_active_runtime_registration_identity(api: &WorkspaceApi) -> ApiResult<()> { + let identities = api.signing_identities.clone(); + let workspace_id = api.config.workspace_id.clone(); + api.store + .db_executor() + .run(move || { + let identity = identities.get_validated(&workspace_id)?; + if identity.state != "active" { + return Err(settings_bad_request( + "workspace_signing_identity_unavailable", + "an active Workspace signing identity is required before registering a Runtime", + )); + } + Ok(()) + }) + .await +} + +async fn scan_runtime_registration_workers(api: &WorkspaceApi, runtime_id: &str) -> ApiResult<()> { + let runtime = api.runtime.clone(); + let runtime_id = runtime_id.to_owned(); + let limit = api.config.max_records.min(200); + api.host_executors.host()?.run(move || { + match runtime.list_workers_for_runtime(&runtime_id, limit) { + Ok(workers) if !workers.items.is_empty() => Err(Error::RuntimeBindingConflict(format!( + "Runtime `{runtime_id}` still has {} active worker(s) and cannot replace its connection", + workers.items.len() + )).into()), + Ok(_) | Err(RuntimeRegistryError::UnknownRuntime(_)) => Ok(()), + Err(error) => Err(error.into_error().into()), + } + }).await +} + +fn publish_workspace_runtime_binding( + api: &WorkspaceApi, + binding: WorkspaceRuntimeBinding, +) -> Result<()> { + api.runtime_binding_expectations + .write() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .insert( + (binding.workspace_id.clone(), binding.runtime_id.clone()), + binding.clone(), + ); + api.runtime_subscription_broker + .unregister_runtime(&binding.runtime_id); + api.register_workspace_runtime_binding(binding) +} + +async fn require_current_runtime_test_binding( + api: &WorkspaceApi, + binding: WorkspaceRuntimeBinding, +) -> ApiResult { + let store = api.store.clone(); + api.store + .db_executor() + .run(move || { + if !store.workspace_runtime_binding_matches(&binding)? { + return Err(Error::RuntimeBindingConflict( + "Runtime connection changed while the connection test was running".to_string(), + ) + .into()); + } + Ok(binding) + }) + .await } async fn create_remote_runtime( @@ -27054,33 +29711,13 @@ async fn create_remote_runtime( "the embedded Runtime is built in and cannot be managed as a remote Runtime", )); } - let identity = api - .signing_identities - .get_validated(&api.config.workspace_id)?; - if identity.state != "active" { - return Err(settings_bad_request( - "workspace_signing_identity_unavailable", - "an active Workspace signing identity is required before registering a Runtime", - )); - } + require_active_runtime_registration_identity(&api).await?; // Protect observed active Workers without removing the current connection. // This scan is not proof of idleness: an unavailable/auth-failing endpoint // must remain repairable by an owner using binding-ID CAS below. Replacement // neither stops nor deletes Workers; Runtime removal has separate preflight. // Rejected key/CAS mutations must leave the current registration usable. - match api - .runtime - .list_workers_for_runtime(&runtime_id, api.config.max_records.min(200)) - { - Ok(workers) if !workers.items.is_empty() => { - return Err(Error::RuntimeBindingConflict(format!( - "Runtime `{runtime_id}` still has {} active worker(s) and cannot replace its connection", - workers.items.len() - )).into()); - } - Ok(_) | Err(RuntimeRegistryError::UnknownRuntime(_)) => {} - Err(error) => return Err(error.into_error().into()), - } + scan_runtime_registration_workers(&api, &runtime_id).await?; let now = Utc::now().to_rfc3339(); let record = WorkspaceRuntimeBinding { workspace_id: api.config.workspace_id.clone(), @@ -27101,30 +29738,32 @@ async fn create_remote_runtime( updated_at: now, revoked_at: None, }; - let (mutation, binding) = api - .store - .put_workspace_runtime_binding_key( - record, - request.expected_binding_id.as_deref(), - &actor.account_id, + let mutation_api = api.clone(); + // One accepted owner keeps SQL commit, local registration and its response + // observation together even when the HTTP waiter disappears. Resource reads + // call the on-Host helper directly, never re-admit to the occupied Host lane. + let (mutation, resource) = run_host_workflow(api.host_executors.host()?, async move { + let (mutation, binding) = mutation_api + .store + .put_workspace_runtime_binding_key( + record, + request.expected_binding_id.as_deref(), + &actor.account_id, + ) + .await?; + publish_workspace_runtime_binding(&mutation_api, binding)?; + let resource = workspace_runtime_resources_response_on_host( + &mutation_api, + mutation_api.workspace_id(), ) - .await?; - api.runtime_binding_expectations - .write() - .unwrap_or_else(std::sync::PoisonError::into_inner) - .insert( - (api.config.workspace_id.clone(), runtime_id.clone()), - binding.clone(), - ); - api.runtime_subscription_broker - .unregister_runtime(&runtime_id); - api.register_workspace_runtime_binding(binding)?; - let resource = workspace_runtime_resources_response(&api, &api.config.workspace_id) .await? .items .into_iter() .find(|resource| resource.runtime.runtime_id == runtime_id) .ok_or_else(|| Error::UnknownRuntime(runtime_id.clone()))?; + Ok::<_, ApiError>((mutation, resource)) + }) + .await?; let status = if mutation == WorkspaceRuntimeBindingMutation::Created { StatusCode::CREATED } else { @@ -27203,6 +29842,19 @@ fn runtime_removal_config_guard( async fn execute_runtime_removal( api: &WorkspaceApi, operation: RuntimeRemovalOperation, +) -> ApiResult { + let api = api.clone(); + // Retain registration/commit recovery as one owned lifecycle workflow; + // SQL transaction boundaries and commit-before-Runtime order are unchanged. + run_host_workflow(api.host_executors.host()?, async move { + execute_runtime_removal_on_host(&api, operation).await + }) + .await +} + +async fn execute_runtime_removal_on_host( + api: &WorkspaceApi, + operation: RuntimeRemovalOperation, ) -> ApiResult { if operation.state == RuntimeRemovalOperationState::Succeeded { return Ok(operation); @@ -27405,9 +30057,16 @@ async fn remove_remote_runtime( return Ok(Json(runtime_removal_response(operation))); } + let config_api = api.clone(); let config_state = api - .config_store - .load_workspace_config(&api.config.workspace_id)? + .store + .db_executor() + .run(move || { + config_api + .config_store + .load_workspace_config(&config_api.config.workspace_id) + }) + .await? .ok_or_else(|| { Error::RegistryInconsistency(format!( "Workspace {} has no active configuration", @@ -27461,22 +30120,15 @@ async fn test_runtime_connection( let checked_at = Utc::now().to_rfc3339(); let runtime = api.runtime.clone(); let ping_runtime_id = runtime_id.clone(); - let ping = tokio::task::spawn_blocking(move || runtime.ping(&ping_runtime_id)) - .await - .map_err(|_| Error::RuntimeOperationFailed { - runtime_id: runtime_id.clone(), - code: "runtime_connection_test_unavailable".to_string(), - message: "Runtime connection test could not be completed".to_string(), - })?; + let ping = api + .host_executors + .host()? + .run(move || Ok::<_, ApiError>(runtime.ping(&ping_runtime_id))) + .await?; - // The ping runs outside the store lock. A replaced/revoked connection must - // not be reported as an observation of the earlier binding. - if !api.store.workspace_runtime_binding_matches(&binding)? { - return Err(Error::RuntimeBindingConflict( - "Runtime connection changed while the connection test was running".to_string(), - ) - .into()); - } + // The ping runs outside the store lock. Recheck the entire live binding on + // the independent DB owner, never acquire its mutex on the Tokio waiter. + let binding = require_current_runtime_test_binding(&api, binding).await?; let mut result = runtime_connection_test_response(api.workspace_id(), &runtime_id, checked_at, ping); // This result describes this attempt, not permission for a future request. @@ -27488,7 +30140,12 @@ async fn test_runtime_connection( async fn get_worker_launch_options( State(api): State, ) -> ApiResult> { - Ok(Json(worker_launch_options_response(&api)?)) + // Runtime summaries can call back into DB signing/authority. Profile config + // reads are synchronous too, but this mixed helper must not occupy sole DB. + api.host_executors + .host()? + .run(move || Ok(Json(worker_launch_options_response(&api)?))) + .await } fn working_directory_request_from_repository( @@ -27593,6 +30250,7 @@ fn browser_worker_spawn_policy( }, WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments }, Some(WorkerTicketAssignmentRequest { + orchestrator_assignment_id: assignment.orchestrator_assignment_id, ticket_id, operation_id, }), @@ -27640,6 +30298,7 @@ fn validate_caller_worker_singleton_key(singleton_key: Option<&str>) -> Result<( Ok(()) } +#[cfg(test)] async fn create_workspace_worker( State(api): State, headers: HeaderMap, @@ -27648,11 +30307,44 @@ async fn create_workspace_worker( create_workspace_worker_inner(api, headers, request, None).await } +#[cfg(test)] async fn create_workspace_worker_inner( api: WorkspaceApi, headers: HeaderMap, request: CreateWorkspaceWorkerRequest, resolved_control_operation: Option, +) -> ApiResult> { + create_workspace_worker_authenticated(api, headers, request, resolved_control_operation, None) + .await +} + +async fn create_workspace_worker_authenticated( + api: WorkspaceApi, + headers: HeaderMap, + request: CreateWorkspaceWorkerRequest, + resolved_control_operation: Option, + context: Option<&server_api::ServerRequestContext>, +) -> ApiResult> { + let context = context.cloned(); + run_host_workflow(api.host_executors.host()?, async move { + create_workspace_worker_authenticated_on_host( + api, + headers, + request, + resolved_control_operation, + context.as_ref(), + ) + .await + }) + .await +} + +async fn create_workspace_worker_authenticated_on_host( + api: WorkspaceApi, + headers: HeaderMap, + request: CreateWorkspaceWorkerRequest, + resolved_control_operation: Option, + context: Option<&server_api::ServerRequestContext>, ) -> ApiResult> { let claim_lock = request .ticket_assignment @@ -27673,7 +30365,23 @@ async fn create_workspace_worker_inner( feature_connections, control_operation_id: _, } = request; - let source = optional_worker_mutation_source(&api, &api.config.workspace_id, &headers)?; + let delegation = ticket_assignment + .as_ref() + .map(|assignment| { + ticket_spawn_delegation( + &api, + &headers, + context, + assignment.orchestrator_assignment_id.as_deref(), + ) + }) + .transpose()? + .flatten(); + let source = if let Some(context) = context { + authenticated_ticket_worker_source(&headers, context)? + } else { + optional_worker_mutation_source(&api, &api.config.workspace_id, &headers)? + }; let config_state = api .config_store .load_workspace_config(&api.config.workspace_id)? @@ -27806,14 +30514,15 @@ async fn create_workspace_worker_inner( if let (Some(assignment), Some(fingerprint)) = (assignment.as_ref(), assignment_fingerprint.as_deref()) { - fresh_ticket_claim = api.store.reserve_ticket_assignment_operation( - &api.config.workspace_id, + fresh_ticket_claim = reserve_ticket_start( + &api, &assignment.operation_id, &assignment.ticket_id, &runtime_id, None, fingerprint, &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + delegation.as_ref(), )?; let operation = api .store @@ -27841,6 +30550,12 @@ async fn create_workspace_worker_inner( .into()); } } + if let Some(assignment) = assignment.as_ref() { + api.store.validate_ticket_assignment_operation_fence( + &api.config.workspace_id, + &assignment.operation_id, + )?; + } let result = match api.spawn_workspace_worker_with_claim_admission( &runtime_id, request, @@ -28262,6 +30977,7 @@ async fn runtime_resource_fetch_contract( message: "Runtime request proof is invalid".to_owned(), })?; server_api::ServerRuntimeSource { + authentication_binding_id: source.authentication_binding_id, runtime_id: source.runtime_id, worker_id: source.worker_id, } @@ -28303,30 +31019,44 @@ fn backend_resource_error_status(error: &BackendResourceError) -> StatusCode { async fn get_companion_status( State(api): State, ) -> ApiResult> { - Ok(Json(api.companion.status())) + api.store + .db_executor() + .run(move || Ok(Json(api.companion.status()))) + .await } async fn get_companion_transcript( State(api): State, Query(query): Query, ) -> ApiResult> { - let limit = query.limit.unwrap_or(api.config.max_records).min(200); - let start = query.start.unwrap_or(0); - Ok(Json(api.companion.transcript(start, limit))) + api.store + .db_executor() + .run(move || { + let limit = query.limit.unwrap_or(api.config.max_records).min(200); + let start = query.start.unwrap_or(0); + Ok(Json(api.companion.transcript(start, limit))) + }) + .await } async fn post_companion_message( State(api): State, Json(request): Json, ) -> ApiResult> { - Ok(Json(api.companion.send_message(request))) + api.store + .db_executor() + .run(move || Ok(Json(api.companion.send_message(request)))) + .await } async fn post_companion_cancel( State(api): State, Json(request): Json, ) -> ApiResult> { - Ok(Json(api.companion.cancel(request))) + api.store + .db_executor() + .run(move || Ok(Json(api.companion.cancel(request)))) + .await } fn runtime_contract_restore( @@ -28376,6 +31106,13 @@ fn resume_internal_worker_restore_intent( request: server_api::WorkerRestoreRequest, ) -> ApiResult { let request_id = request.request_id.clone(); + if request_id.starts_with("manual-worker-restore:") { + let operation_id = request_id + .strip_prefix("manual-worker-restore:") + .expect("checked prefix"); + api.store + .validate_ticket_assignment_operation_fence(&api.config.workspace_id, operation_id)?; + } let result = match WorkspaceWorker::resolve(api, &worker.runtime_id, &worker.worker_id)? .restore_internal(request) { @@ -28418,25 +31155,30 @@ async fn get_runtime_worker( State(api): State, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, ) -> ApiResult> { - let worker_ref = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; - let worker = api - .runtime - .worker(&worker_ref) - .map_err(|err| err.into_error())?; - let record = sync_worker_observation(&api, &worker)?; - let links = api - .store - .list_worker_workdir_links(&api.config.workspace_id, &record.worker)?; - let workdirs = api - .store - .list_workdir_registry(&api.config.workspace_id, 500)?; - let updated_at = record.updated_at.clone(); - let worker = - project_worker_registry_projection(&api, Some(&worker), &record, links, &workdirs)?; - Ok(Json(server_api::RuntimeWorkerShowResponse { - worker, - updated_at, - })) + api.host_executors + .host()? + .run(move || { + let worker_ref = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; + let worker = api + .runtime + .worker(&worker_ref) + .map_err(|err| err.into_error())?; + let record = sync_worker_observation(&api, &worker)?; + let links = api + .store + .list_worker_workdir_links(&api.config.workspace_id, &record.worker)?; + let workdirs = api + .store + .list_workdir_registry(&api.config.workspace_id, 500)?; + let updated_at = record.updated_at.clone(); + let worker = + project_worker_registry_projection(&api, Some(&worker), &record, links, &workdirs)?; + Ok(Json(server_api::RuntimeWorkerShowResponse { + worker, + updated_at, + })) + }) + .await } async fn restore_runtime_worker_with_context( @@ -28446,46 +31188,82 @@ async fn restore_runtime_worker_with_context( context: WorkerOperationContext, request: server_api::WorkerRestoreRequest, ) -> ApiResult> { - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id)?; - let mut result = worker.restore(&context, request).await?; - let projected_worker = if let Some(worker) = result.worker.as_ref() { - // A replayed terminal receipt is historical evidence, not a new Runtime observation. - let record = api - .store - .get_worker_registry(api.workspace_id(), &worker.worker)? - .ok_or_else(|| Error::UnknownWorker { - worker: worker.worker.clone(), - })?; - let links = api - .store - .list_worker_workdir_links(&api.config.workspace_id, &record.worker)?; - let workdirs = api - .store - .list_workdir_registry(&api.config.workspace_id, 500)?; - match project_worker_registry_projection(&api, Some(worker), &record, links, &workdirs) { - Ok(worker) => Some(worker), - Err(error) => { - result.diagnostics.push(RuntimeDiagnostic::new( - "worker_restore_current_projection_unavailable", - "warning", - sanitize_backend_error(&error.error.to_string()), - )); + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id).await?; + let result = worker.restore(&context, request).await?; + project_worker_restore_response_owned(api, runtime_id, worker_id, result).await +} + +// Post-restore stored projection is its own whole DB stage. Historical terminal +// receipts are never turned into a fresh Runtime observation or registry write. +async fn project_worker_restore_response_owned( + api: WorkspaceApi, + runtime_id: String, + worker_id: String, + mut result: crate::hosts::InternalWorkerRestoreResult, +) -> ApiResult> { + // A terminal result without a Worker has no stored projection to read. + // Do not turn that pure response into a new availability failure on DB overload. + if result.worker.is_none() { + return Ok(Json(server_api::WorkerRestoreResponse { + workspace_id: api.workspace_id().to_string(), + runtime_id, + worker_id, + result: server_api::WorkerRestoreResult { + state: result.state, + worker: None, + diagnostics: result.diagnostics.into_iter().map(Into::into).collect(), + }, + })); + } + api.store + .db_executor() + .run(move || { + let projected_worker = if let Some(worker) = result.worker.as_ref() { + // A replayed terminal receipt is historical evidence, not a new Runtime observation. + let record = api + .store + .get_worker_registry(api.workspace_id(), &worker.worker)? + .ok_or_else(|| Error::UnknownWorker { + worker: worker.worker.clone(), + })?; + let links = api + .store + .list_worker_workdir_links(&api.config.workspace_id, &record.worker)?; + let workdirs = api + .store + .list_workdir_registry(&api.config.workspace_id, 500)?; + match project_worker_registry_projection( + &api, + Some(worker), + &record, + links, + &workdirs, + ) { + Ok(worker) => Some(worker), + Err(error) => { + result.diagnostics.push(RuntimeDiagnostic::new( + "worker_restore_current_projection_unavailable", + "warning", + sanitize_backend_error(&error.error.to_string()), + )); + None + } + } + } else { None - } - } - } else { - None - }; - Ok(Json(server_api::WorkerRestoreResponse { - workspace_id: api.workspace_id().to_string(), - runtime_id: runtime_id.clone(), - worker_id: worker_id.clone(), - result: server_api::WorkerRestoreResult { - state: result.state, - worker: projected_worker, - diagnostics: result.diagnostics.into_iter().map(Into::into).collect(), - }, - })) + }; + Ok(Json(server_api::WorkerRestoreResponse { + workspace_id: api.workspace_id().to_string(), + runtime_id: runtime_id.clone(), + worker_id: worker_id.clone(), + result: server_api::WorkerRestoreResult { + state: result.state, + worker: projected_worker, + diagnostics: result.diagnostics.into_iter().map(Into::into).collect(), + }, + })) + }) + .await } fn reject_workdir_for_embedded_runtime(runtime_id: &str, has_workdir: bool) -> ApiResult<()> { @@ -28511,33 +31289,38 @@ async fn list_runtime_workers( AxumPath(runtime_id): AxumPath, Query(query): Query, ) -> ApiResult> { - let limit = api.config.max_records.min(200); - let (runtime_workers, source) = match query.status { - Some(RuntimeWorkersStatusFilter::Stopped) => ( - api.runtime - .list_stopped_workers_for_runtime(&runtime_id, limit) - .map_err(|error| error.into_error())?, - "runtime_registry_stopped", - ), - None => ( - api.runtime - .list_workers_for_runtime(&runtime_id, limit) - .map_err(|error| error.into_error())?, - "runtime_registry", - ), - }; - let items = project_observed_workspace_workers(&api, runtime_workers.items)?; - Ok(Json(server_api::RuntimeWorkerListResponse { - workspace_id: api.workspace_id().to_string(), - limit, - items, - source: source.to_string(), - diagnostics: runtime_workers - .diagnostics - .into_iter() - .map(Into::into) - .collect(), - })) + api.host_executors + .host()? + .run(move || { + let limit = api.config.max_records.min(200); + let (runtime_workers, source) = match query.status { + Some(RuntimeWorkersStatusFilter::Stopped) => ( + api.runtime + .list_stopped_workers_for_runtime(&runtime_id, limit) + .map_err(|error| error.into_error())?, + "runtime_registry_stopped", + ), + None => ( + api.runtime + .list_workers_for_runtime(&runtime_id, limit) + .map_err(|error| error.into_error())?, + "runtime_registry", + ), + }; + let items = project_observed_workspace_workers(&api, runtime_workers.items)?; + Ok(Json(server_api::RuntimeWorkerListResponse { + workspace_id: api.workspace_id().to_string(), + limit, + items, + source: source.to_string(), + diagnostics: runtime_workers + .diagnostics + .into_iter() + .map(Into::into) + .collect(), + })) + }) + .await } #[derive(Clone, Copy, Debug, PartialEq, Eq)] @@ -28924,12 +31707,59 @@ fn runtime_diagnostics_message(diagnostics: &[RuntimeDiagnostic]) -> String { } } +#[cfg(test)] async fn create_runtime_worker( + api: State, + runtime_id: AxumPath, + request: Json, +) -> ApiResult> { + create_runtime_worker_authenticated(api, runtime_id, request, None).await +} + +async fn create_runtime_worker_authenticated( State(api): State, AxumPath(runtime_id): AxumPath, Json(request): Json, + context: Option<&server_api::ServerRequestContext>, +) -> ApiResult> { + let context = context.cloned(); + run_host_workflow(api.host_executors.host()?, async move { + create_runtime_worker_authenticated_on_host( + State(api), + AxumPath(runtime_id), + Json(request), + context.as_ref(), + ) + .await + }) + .await +} + +async fn create_runtime_worker_authenticated_on_host( + State(api): State, + AxumPath(runtime_id): AxumPath, + Json(request): Json, + context: Option<&server_api::ServerRequestContext>, ) -> ApiResult> { let mut request = worker_spawn_request_from_api(request)?; + let headers = context + .map(contract_request_headers) + .transpose() + .map_err(|error| Error::InvalidInput(error.message))? + .unwrap_or_default(); + let delegation = request + .ticket_assignment + .as_ref() + .map(|assignment| { + ticket_spawn_delegation( + &api, + &headers, + context, + assignment.orchestrator_assignment_id.as_deref(), + ) + }) + .transpose()? + .flatten(); let claim_lock = request .ticket_assignment .as_ref() @@ -28996,14 +31826,15 @@ async fn create_runtime_worker( if let (Some(assignment), Some((_, fingerprint))) = (lifecycle_assignment.as_ref(), spawn_idempotency.as_ref()) { - fresh_ticket_claim = match api.store.reserve_ticket_assignment_operation( - &api.config.workspace_id, + fresh_ticket_claim = match reserve_ticket_start( + &api, &assignment.operation_id, &assignment.ticket_id, &runtime_id, None, fingerprint, &Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + delegation.as_ref(), ) { Ok(fresh) => fresh, Err(error) => { @@ -29023,6 +31854,12 @@ async fn create_runtime_worker( diagnostics: Vec::new(), })?)); } + if let Some(assignment) = lifecycle_assignment.as_ref() { + api.store.validate_ticket_assignment_operation_fence( + &api.config.workspace_id, + &assignment.operation_id, + )?; + } let result = api.spawn_workspace_worker_with_claim_admission(&runtime_id, request, fresh_ticket_claim)?; if let Some(worker) = result.worker.as_ref() { @@ -29104,17 +31941,22 @@ async fn sync_runtime_config_bundle( AxumPath(runtime_id): AxumPath, Json(request): Json, ) -> ApiResult> { - let bundle = serde_json::from_value::(request.bundle) - .map_err(|error| invalid_contract_value("Runtime ConfigBundle", error))?; - let result = api - .runtime - .sync_config_bundle(&runtime_id, bundle) - .map_err(|err| err.into_error())?; - Ok(Json(runtime_config_bundle_result_to_api( - result.state, - result.availability, - result.diagnostics, - )?)) + api.host_executors + .host()? + .run(move || { + let bundle = serde_json::from_value::(request.bundle) + .map_err(|error| invalid_contract_value("Runtime ConfigBundle", error))?; + let result = api + .runtime + .sync_config_bundle(&runtime_id, bundle) + .map_err(|err| err.into_error())?; + Ok(Json(runtime_config_bundle_result_to_api( + result.state, + result.availability, + result.diagnostics, + )?)) + }) + .await } async fn check_runtime_config_bundle( @@ -29122,21 +31964,26 @@ async fn check_runtime_config_bundle( AxumPath((runtime_id, bundle_id)): AxumPath<(String, String)>, Query(query): Query, ) -> ApiResult> { - let result = api - .runtime - .check_config_bundle( - &runtime_id, - ConfigBundleRef { - id: bundle_id, - digest: query.digest, - }, - ) - .map_err(|err| err.into_error())?; - Ok(Json(runtime_config_bundle_result_to_api( - result.state, - result.availability, - result.diagnostics, - )?)) + api.host_executors + .host()? + .run(move || { + let result = api + .runtime + .check_config_bundle( + &runtime_id, + ConfigBundleRef { + id: bundle_id, + digest: query.digest, + }, + ) + .map_err(|err| err.into_error())?; + Ok(Json(runtime_config_bundle_result_to_api( + result.state, + result.availability, + result.diagnostics, + )?)) + }) + .await } #[cfg(test)] @@ -29156,7 +32003,7 @@ async fn dispatch_runtime_worker_input( worker_id: String, request: WorkerInputRequest, ) -> ApiResult> { - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id)?; + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id).await?; let result = worker .input(&WorkerOperationContext::Backend, request) .await?; @@ -29167,11 +32014,16 @@ async fn delete_runtime_worker_uploaded_file( State(api): State, AxumPath((runtime_id, worker_id, artifact_id)): AxumPath<(String, String, String)>, ) -> ApiResult> { - let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; - api.runtime - .delete_worker_uploaded_file(&worker, &artifact_id) - .map_err(|err| err.into_error())?; - Ok(Json(WorkerFileDeleteResponse { deleted: true })) + api.host_executors + .host()? + .run(move || { + let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; + api.runtime + .delete_worker_uploaded_file(&worker, &artifact_id) + .map_err(|err| err.into_error())?; + Ok(Json(WorkerFileDeleteResponse { deleted: true })) + }) + .await } async fn runtime_worker_completions( @@ -29179,13 +32031,18 @@ async fn runtime_worker_completions( AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, Json(request): Json, ) -> ApiResult> { - let request = worker_completions_request_from_api(request)?; - let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; - let result = api - .runtime - .worker_completions(&worker, request) - .map_err(|err| err.into_error())?; - Ok(Json(worker_completions_result_to_api(result)?)) + api.host_executors + .host()? + .run(move || { + let request = worker_completions_request_from_api(request)?; + let worker = resolve_workspace_worker_reference(&api, &runtime_id, &worker_id)?; + let result = api + .runtime + .worker_completions(&worker, request) + .map_err(|err| err.into_error())?; + Ok(Json(worker_completions_result_to_api(result)?)) + }) + .await } fn authenticated_browser_input_source(actor: &RequestActor) -> protocol::AuthenticatedInputSource { @@ -29245,7 +32102,7 @@ async fn worker_protocol_ws( headers: HeaderMap, ws: WebSocketUpgrade, ) -> Response { - let worker = match WorkspaceWorker::resolve(&api, &runtime_id, &worker_id) { + let worker = match WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id).await { Ok(worker) => worker, Err(error) => return ApiError::from(error).into_response(), }; @@ -29257,62 +32114,57 @@ async fn worker_protocol_ws( match worker.connect_protocol(&context).await { Ok(connection) => workspace_worker_protocol_session(connection, socket, context).await, Err(error) => { - let _ = send_protocol_event(&mut socket, &protocol_error_event(error.to_string())) - .await; - let _ = socket.send(WsMessage::Close(None)).await; + let _ = tokio::time::timeout(WORKER_PROTOCOL_TRANSPORT_TIMEOUT, async { + let _ = + send_protocol_event(&mut socket, &protocol_error_event(error.to_string())) + .await; + let _ = socket.send(WsMessage::Close(None)).await; + }) + .await; } } }) .into_response() } -async fn workspace_worker_protocol_session( - mut connection: worker_operations::WorkspaceWorkerConnection, - mut socket: WebSocket, - context: WorkerOperationContext, -) { - loop { - tokio::select! { - inbound = socket.next() => match inbound { - Some(Ok(WsMessage::Text(text))) => { - let result = match decode_method(&text) { - Ok(method) => connection.sender.send(&context, method).await.map_err(|error| error.to_string()), - Err(error) => Err(error.to_string()), - }; - if let Err(error) = result { - if !send_protocol_event(&mut socket, &protocol_error_event(error)).await { break; } - } - } - Some(Ok(WsMessage::Ping(value))) => { if socket.send(WsMessage::Pong(value)).await.is_err() { break; } } - Some(Ok(WsMessage::Pong(_))) => {} - _ => break, - }, - event = connection.events.recv() => { - let Some(event) = event else { break; }; - let shutdown = matches!(event, protocol::Event::Shutdown); - if !send_protocol_event(&mut socket, &event).await || shutdown { break; } - } - } - } - let _ = socket.send(WsMessage::Close(None)).await; -} - -pub(crate) struct WorkspaceWorkerProtocolConnection { - pub(crate) methods: tokio::sync::mpsc::Sender, - pub(crate) events: tokio::sync::mpsc::Receiver, -} - pub(crate) async fn connect_workspace_worker_protocol( api: &WorkspaceApi, worker: &RuntimeWorkerRef, input_source: Option<&protocol::AuthenticatedInputSource>, ) -> Result { + let source = workspace_worker_protocol_source(api, worker).await?; + match source { + RuntimeObservationSource::RemoteWs(config) => { + connect_remote_worker_protocol(config, input_source).await + } + RuntimeObservationSource::Embedded(source) => { + let scope = + worker_runtime::RuntimeWorkspaceScope::new(api.workspace_id(), "embedded-backend"); + connect_embedded_worker_protocol(source, scope, api.host_executors.host()?).await + } + } +} + +// Cold source discovery includes live authority/signing reads. Keep the whole +// synchronous callback on its admitted DB owner, not the Tokio WS task. +async fn workspace_worker_protocol_source( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> Result { let source = match api.observation_proxy.source(worker) { Ok(source) => source, - Err(ObservationProxyError::WorkerNotFound(_)) => api - .runtime - .observation_source(worker) - .map_err(|error| error.into_error())?, + Err(ObservationProxyError::WorkerNotFound(_)) => { + let runtime = api.runtime.clone(); + let worker = worker.clone(); + api.store + .db_executor() + .run(move || { + runtime + .observation_source(&worker) + .map_err(|error| error.into_error()) + }) + .await? + } Err(error) => { return Err(Error::RuntimeOperationFailed { runtime_id: worker.runtime_id.clone(), @@ -29321,16 +32173,7 @@ pub(crate) async fn connect_workspace_worker_protocol( }); } }; - match source { - RuntimeObservationSource::RemoteWs(config) => { - connect_remote_worker_protocol(config, input_source).await - } - RuntimeObservationSource::Embedded(source) => { - let scope = - worker_runtime::RuntimeWorkspaceScope::new(api.workspace_id(), "embedded-backend"); - connect_embedded_worker_protocol(source, scope).await - } - } + Ok(source) } fn insert_authenticated_input_source_header( @@ -29373,97 +32216,102 @@ async fn connect_remote_worker_protocol( } insert_authenticated_input_source_header(request.headers_mut(), input_source)?; let (socket, _) = - connect_async(request) + tokio::time::timeout(WORKER_PROTOCOL_TRANSPORT_TIMEOUT, connect_async(request)) .await + .map_err(|_| Error::RuntimeOperationFailed { + runtime_id: config.worker.runtime_id.clone(), + code: "worker_protocol_connect_timeout".into(), + message: "Worker protocol connection handshake timed out; no method was admitted" + .into(), + })? .map_err(|error| Error::RuntimeOperationFailed { runtime_id: config.worker.runtime_id.clone(), - code: "worker_protocol_connect_failed".to_string(), + code: "worker_protocol_connect_failed".into(), message: error.to_string(), })?; - let (mut sink, mut stream) = socket.split(); - let (methods, mut method_receiver) = tokio::sync::mpsc::channel(256); - let (event_sender, events) = tokio::sync::mpsc::channel(512); - tokio::spawn(async move { - loop { - tokio::select! { - method = method_receiver.recv() => { - let Some(method) = method else { break }; - let Ok(text) = protocol::stream::encode_method(&method) else { break }; - if sink.send(TungsteniteMessage::Text(text.into())).await.is_err() { break; } - } - message = stream.next() => match message { - Some(Ok(TungsteniteMessage::Text(text))) => { - let Ok(event) = protocol::stream::decode_event(text.as_ref()) else { break; }; - if event_sender.send(event).await.is_err() { break; } - } - Some(Ok(TungsteniteMessage::Ping(value))) => { - if sink.send(TungsteniteMessage::Pong(value)).await.is_err() { break; } - } - Some(Ok(TungsteniteMessage::Pong(_))) => {} - _ => break, - } - } - } - }); - Ok(WorkspaceWorkerProtocolConnection { methods, events }) + let (sink, stream) = socket.split(); + Ok(remote_worker_protocol_bridge(sink, stream)) } async fn connect_embedded_worker_protocol( source: crate::observation::EmbeddedRuntimeObservationSource, scope: worker_runtime::RuntimeWorkspaceScope, + executor: crate::db_executor::DbExecutor, ) -> Result { - // Capture one real execution endpoint, including its greeting/snapshot. - // The WorkerRef-wide Workspace observation bus is a separate catalogue - // channel and must not reconnect this operational session after Restore. - let mut transport = source - .runtime - .attach_worker_protocol_scoped(&scope, &source.worker_ref) - .map_err(|error| Error::RuntimeOperationFailed { - runtime_id: source.worker.runtime_id.clone(), - code: "embedded_worker_protocol_connect_failed".to_string(), - message: error.to_string(), - })?; - let (methods, mut method_receiver) = tokio::sync::mpsc::channel(256); - let (event_sender, events) = tokio::sync::mpsc::channel(512); - tokio::spawn(async move { - if event_sender.send(transport.snapshot.clone()).await.is_err() { - return; - } - loop { - tokio::select! { - _ = event_sender.closed() => break, - method = method_receiver.recv() => { - let Some(method) = method else { break }; - let shutdown = matches!(&method, protocol::Method::Shutdown { .. }); - match source.runtime.send_connected_protocol_method_scoped( - &scope, &source.worker_ref, &transport, method, - ) { - Ok(direct_events) => { - for event in direct_events { - let terminal = matches!(event, protocol::Event::Shutdown); - if event_sender.send(event).await.is_err() || terminal { return; } + // Endpoint capture takes Runtime operation/state locks. Keep it, and each + // intact method (including fs-store commit/publication), off Tokio and the + // sole DB lane. A discarded reply retains the endpoint until cleanup. + let capture_source = source.clone(); + let capture_scope = scope.clone(); + let mut transport = executor + .run(move || { + capture_source + .runtime + .attach_worker_protocol_scoped(&capture_scope, &capture_source.worker_ref) + .map_err(|error| Error::RuntimeOperationFailed { + runtime_id: capture_source.worker.runtime_id.clone(), + code: "embedded_worker_protocol_connect_failed".to_string(), + message: error.to_string(), + }) + }) + .await?; + Ok(spawn_worker_protocol_pump( + move |mut methods, events| async move { + if let Err(reason) = enqueue_worker_protocol_event(&events, transport.snapshot.clone()) + { + return reason; + } + loop { + tokio::select! { + method = methods.recv() => { + let Some(method) = method else { return WorkerProtocolEnd::Disconnected; }; + let shutdown = matches!(&method, protocol::Method::Shutdown { .. }); + let method_source = source.clone(); + let method_scope = scope.clone(); + let owned = executor.run(move || { + let result = method_source.runtime.send_connected_protocol_method_scoped( + &method_scope, &method_source.worker_ref, &transport, method, + ); + // Endpoint validation is synchronous too. + let terminal = result.as_ref().err().is_some_and(|error| + embedded_protocol_dispatch_is_terminal(error, &transport)); + Ok::<_, Error>((transport, result, terminal)) + }).await; + let (returned, result, terminal) = match owned { + Ok(value) => value, + Err(error) => { + let _ = enqueue_worker_protocol_event(&events, protocol_error_event(error.to_string())); + return WorkerProtocolEnd::Disconnected; + } + }; + transport = returned; + match result { + Ok(direct_events) => { + for event in direct_events { + let terminal = matches!(event, protocol::Event::Shutdown); + if let Err(reason) = enqueue_worker_protocol_event(&events, event) { return reason; } + if terminal { return WorkerProtocolEnd::Disconnected; } + } + } + Err(error) => { + if let Err(reason) = enqueue_worker_protocol_event(&events, protocol_error_event(error.to_string())) { return reason; } + if terminal { return WorkerProtocolEnd::Disconnected; } } } - Err(error) => { - let terminal = embedded_protocol_dispatch_is_terminal(&error, &transport); - if event_sender.send(protocol_error_event(error.to_string())).await.is_err() || terminal { return; } + if shutdown { return WorkerProtocolEnd::Disconnected; } + } + event = transport.events.recv() => match event { + Some(event) => { + let terminal = matches!(event, protocol::Event::Shutdown); + if let Err(reason) = enqueue_worker_protocol_event(&events, event) { return reason; } + if terminal { return WorkerProtocolEnd::Disconnected; } } + None => return WorkerProtocolEnd::Disconnected, } - if shutdown { break; } - } - event = transport.events.recv() => match event { - Some(event) => { - let terminal = matches!(event, protocol::Event::Shutdown); - if event_sender.send(event).await.is_err() || terminal { break; } - } - // The execution relay closes on Stop, disconnect or lag. - // Uncertain streams are terminal, never grounds to reattach. - None => break, } } - } - }); - Ok(WorkspaceWorkerProtocolConnection { methods, events }) + }, + )) } fn embedded_protocol_dispatch_is_terminal( @@ -29802,23 +32650,28 @@ async fn list_host_workers( State(api): State, AxumPath(host_id): AxumPath, ) -> ApiResult> { - let limit = api.config.max_records.min(200); - let runtime_workers = api - .runtime - .list_workers_for_host(&host_id, limit) - .map_err(|err| err.into_error())?; - let items = project_observed_workspace_workers(&api, runtime_workers.items)?; - Ok(Json(server_api::HostWorkerListResponse { - workspace_id: api.workspace_id().to_string(), - limit, - items, - source: "worker_runtime_registry".to_string(), - diagnostics: runtime_workers - .diagnostics - .into_iter() - .map(Into::into) - .collect(), - })) + api.host_executors + .host()? + .run(move || { + let limit = api.config.max_records.min(200); + let runtime_workers = api + .runtime + .list_workers_for_host(&host_id, limit) + .map_err(|err| err.into_error())?; + let items = project_observed_workspace_workers(&api, runtime_workers.items)?; + Ok(Json(server_api::HostWorkerListResponse { + workspace_id: api.workspace_id().to_string(), + limit, + items, + source: "worker_runtime_registry".to_string(), + diagnostics: runtime_workers + .diagnostics + .into_iter() + .map(Into::into) + .collect(), + })) + }) + .await } fn project_workspace_worker_with_attachments( @@ -29917,6 +32770,20 @@ fn workers_response(api: WorkspaceApi) -> ApiResult ApiResult { + let api = api.clone(); + let workspace_id = workspace_id.to_owned(); + run_host_workflow(api.host_executors.host()?, async move { + workspace_runtime_resources_response_on_host(&api, &workspace_id).await + }) + .await +} + +// Already-owned Runtime workflows call this directly; re-admission to Host would +// wait behind the same parents. DB binding helpers still use their own DB owner. +async fn workspace_runtime_resources_response_on_host( + api: &WorkspaceApi, + workspace_id: &str, ) -> ApiResult { let limit = api.config.max_records.min(200); let runtimes = api.runtime.list_runtimes(limit); @@ -30018,12 +32885,26 @@ async fn workspace_runtime_detail( api: &WorkspaceApi, workspace_id: &str, runtime_id: &str, +) -> ApiResult { + let api = api.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + run_host_workflow(api.host_executors.host()?, async move { + workspace_runtime_detail_on_host(&api, &workspace_id, &runtime_id).await + }) + .await +} + +async fn workspace_runtime_detail_on_host( + api: &WorkspaceApi, + workspace_id: &str, + runtime_id: &str, ) -> ApiResult { let binding = api .store .get_workspace_runtime_binding(workspace_id, runtime_id) .await?; - let mut resource = workspace_runtime_resources_response(api, workspace_id) + let mut resource = workspace_runtime_resources_response_on_host(api, workspace_id) .await? .items .into_iter() @@ -32377,9 +35258,33 @@ impl From for ApiError { } } +impl From for server_api::RepositoryApiError { + fn from(error: Error) -> Self { + ApiError::from(error).into_repository_api_error() + } +} + +impl From for server_api::RuntimeManagementApiError { + fn from(error: Error) -> Self { + Self::from(server_api::RepositoryApiError::from(error)) + } +} + impl From for ApiError { fn from(error: Error) -> Self { let diagnostics = match &error { + Error::DbCapacityExceeded + | Error::DbExecutorUnavailable + | Error::DbOperationOutcomeUnknown => vec![RuntimeDiagnostic { + code: match &error { + Error::DbCapacityExceeded => "db_capacity_exceeded", + Error::DbExecutorUnavailable => "db_executor_unavailable", + _ => "db_operation_outcome_unknown", + } + .into(), + severity: HostDiagnosticSeverity::Error, + message: error.to_string(), + }], Error::WorkdirSessionOpenFailed { transport, .. } => vec![RuntimeDiagnostic { code: "workdir_session_open_failed".to_string(), severity: HostDiagnosticSeverity::Error, @@ -32402,6 +35307,7 @@ impl From for ApiError { ticket::TicketError::Locked { .. } => "ticket_locked", ticket::TicketError::Conflict(_) | ticket::TicketError::StaleWorkflowState { .. } + | ticket::TicketError::StaleClosureOperationKey { .. } | ticket::TicketError::StaleContent { .. } | ticket::TicketError::InvalidWorkflowTransition { .. } | ticket::TicketError::BlockingRelations(_) @@ -32523,6 +35429,7 @@ fn api_error_status(error: &Error) -> StatusCode { | ticket::TicketError::Conflict(_) | ticket::TicketError::StaleContent { .. } | ticket::TicketError::StaleWorkflowState { .. } + | ticket::TicketError::StaleClosureOperationKey { .. } | ticket::TicketError::OperationFingerprintMismatch { .. }, ) => StatusCode::CONFLICT, Error::Ticket( @@ -32601,6 +35508,8 @@ fn api_error_status(error: &Error) -> StatusCode { { StatusCode::SERVICE_UNAVAILABLE } + Error::DbCapacityExceeded | Error::DbExecutorUnavailable => StatusCode::SERVICE_UNAVAILABLE, + Error::DbOperationOutcomeUnknown => StatusCode::SERVICE_UNAVAILABLE, Error::WorkspaceSigningIdentity { .. } => StatusCode::SERVICE_UNAVAILABLE, Error::WorkdirSessionOpenFailed { .. } | Error::RuntimeOperationFailed { .. } => { StatusCode::BAD_GATEWAY @@ -32658,14 +35567,39 @@ impl IntoResponse for ApiError { } } +#[cfg(test)] +#[path = "server_db_execution_tests.rs"] +mod db_execution_tests; + #[cfg(test)] mod tests { + mod assignment_delivery_tests { + include!("server_assignment_delivery_tests.rs"); + } + + mod assignment_start_tests { + include!("server_assignment_start_tests.rs"); + } + mod assignment_tracking_tests { + include!("server_assignment_tracking_tests.rs"); + } + + mod ticket_case_signed_tests { + include!("server_ticket_case_signed_tests.rs"); + } + + mod ticket_tags_tests { + include!("server_ticket_tags_tests.rs"); + } + mod api_logging_tests; mod auth_logging_tests; mod drive_api_tests; mod drive_tests; mod drive_web_roundtrip_tests; + mod embedded_owned_execution_tests; mod external_checkout_tests; + mod protocol_backpressure_tests; mod subject_spawn_tests; mod subjektiv_jobs_tests; mod ticket_evidence_tests; @@ -32675,7 +35609,9 @@ mod tests { mod workdir_delegation_tests; mod worker_drive_tests; mod worker_operations_tests; + mod worker_protocol_tests; mod workspace_admission_tests; + mod workspace_protocol_backpressure_tests; include!("server_workspace_config_tests.rs"); use super::*; use axum::body::{Body, to_bytes}; @@ -33579,6 +36515,73 @@ mod tests { ); } + #[tokio::test] + async fn cancelled_workdir_db_waiter_retains_session_lock_until_mutation_finishes() { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let api = test_api(dir.path()).await; + let worker = RuntimeWorkerRef::new("guard-runtime", "guard-worker"); + let lock = current_worker_session_lock(&api, &worker); + let guard = Arc::new(lock.clone().lock_owned().await); + let now = Utc::now().to_rfc3339_opts(SecondsFormat::Nanos, true); + let record = WorkdirRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + workdir_id: "cancelled-waiter-workdir".to_string(), + display_name: Some("Accepted mutation".to_string()), + source: WorkdirRegistrySource::Repository { + runtime_id: worker.runtime_id.clone(), + repository_id: api.config.repositories[0].id.clone(), + }, + creation_selector: None, + creation_ref: None, + creation_tree: None, + current_selector: None, + current_ref: None, + current_tree: None, + observed_at_epoch_seconds: None, + materialization_status: "present".to_string(), + cleanliness: "unknown".to_string(), + created_at: now.clone(), + updated_at: now, + }; + let (started, accepted) = tokio::sync::oneshot::channel(); + let (release, released) = std::sync::mpsc::channel(); + let stage_api = api.clone(); + let waiter = tokio::spawn(async move { + run_worker_workdir_db_stage_locked(&stage_api, &guard, move |api| { + let _ = started.send(()); + released + .recv_timeout(std::time::Duration::from_secs(5)) + .map_err(|error| Error::Store(error.to_string()))?; + api.store.upsert_workdir_registry(&record)?; + Ok::<_, Error>(()) + }) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(5), accepted) + .await + .unwrap() + .unwrap(); + waiter.abort(); + assert!(waiter.await.unwrap_err().is_cancelled()); + // This is the same Worker lock, not an executor-capacity proxy. + let still_locked = lock.try_lock().is_err(); + release.send(()).unwrap(); + assert!( + still_locked, + "accepted mutation lost its Worker session guard" + ); + let _guard = tokio::time::timeout(std::time::Duration::from_secs(5), lock.lock()) + .await + .expect("accepted mutation did not release its Worker lock"); + let persisted = api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, "cancelled-waiter-workdir") + .unwrap() + .expect("accepted mutation was cancelled with its waiter"); + assert_eq!(persisted.display_name.as_deref(), Some("Accepted mutation")); + } + #[tokio::test] async fn backend_expiry_fences_offline_grant_and_persists_cleanup_completion() { let dir = tempfile::tempdir().unwrap(); @@ -33772,10 +36775,10 @@ mod tests { inject_external_workdir_cleanup_failure(&grant.workdir_id); let session_lock = current_worker_session_lock(&api, &worker); - let session_guard = session_lock.lock().await; + let session_guard = Arc::new(session_lock.lock_owned().await); let error = tokio::time::timeout( std::time::Duration::from_secs(1), - open_current_worker_workdir_session_locked(&api, &worker, &link), + open_current_worker_workdir_session_locked(&api, &worker, &link, &session_guard), ) .await .expect("session-open expiry cleanup must not reacquire its Worker lock") @@ -34320,10 +37323,11 @@ mod tests { ); let session_lock = current_worker_session_lock(&api, &worker_ref); - let session_guard = session_lock.lock().await; - let session = open_current_worker_workdir_session_locked(&api, &worker_ref, link) - .await - .unwrap(); + let session_guard = Arc::new(session_lock.lock_owned().await); + let session = + open_current_worker_workdir_session_locked(&api, &worker_ref, link, &session_guard) + .await + .unwrap(); drop(session_guard); assert_eq!(session.capabilities(), expected_capabilities); if index == 0 { @@ -34746,11 +37750,15 @@ mod tests { workdir::WorkdirSessionCapabilities::ALL ); let session_lock = current_worker_session_lock(&api, &worker); - let session_guard = session_lock.lock().await; - let broker_session = - open_current_worker_workdir_session_locked(&api, &worker, &external_link) - .await - .unwrap(); + let session_guard = Arc::new(session_lock.lock_owned().await); + let broker_session = open_current_worker_workdir_session_locked( + &api, + &worker, + &external_link, + &session_guard, + ) + .await + .unwrap(); assert_eq!( broker_session.capabilities(), workdir::WorkdirSessionCapabilities::ALL @@ -34947,6 +37955,7 @@ mod tests { Arc>>, reject_repository_access: Arc>, lifecycle_callback: Option>, + stop_callback: Option>, next_restore_outcome: Arc>>, restore_attempts: Arc>, } @@ -35253,6 +38262,9 @@ mod tests { worker_id: &str, _request: WorkerLifecycleRequest, ) -> WorkerLifecycleResult { + if let Some(callback) = &self.stop_callback { + callback(); + } WorkerLifecycleResult { state: InternalWorkerOperationState::Accepted, worker: RuntimeWorkerRef::new(Self::RUNTIME_ID, worker_id), @@ -35926,6 +38938,112 @@ mod tests { assert_eq!(error.error, "Payload Too Large"); } + #[tokio::test(flavor = "current_thread")] + async fn accepted_stop_closes_sessions_even_when_its_waiter_is_cancelled() { + let directory = tempfile::tempdir().unwrap(); + let api = test_api(directory.path()).await; + api.worker_projection.shutdown_async().await; + api.store.db_executor().drain().await.unwrap(); + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + let stopped = Arc::new(tokio::sync::Notify::new()); + let notification = stopped.clone(); + let runtime = WorkdirlessFixtureRuntime { + stop_callback: Some(Arc::new(move || notification.notify_one())), + ..Default::default() + }; + api.runtime.register_or_replace(runtime.clone()); + let identity = RuntimeWorkerRef::new( + WorkdirlessFixtureRuntime::RUNTIME_ID, + Uuid::now_v7().to_string(), + ); + api.store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + worker: identity.clone(), + display_name: "stop owner fixture".into(), + profile: None, + retention_state: "normal".into(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "1".into(), + updated_at: "1".into(), + }) + .unwrap(); + runtime + .workers + .lock() + .unwrap() + .push(worker_summary_from_registry( + &api.store + .get_worker_registry(TEST_WORKSPACE_ID, &identity) + .unwrap() + .unwrap(), + )); + let session: WorkdirSessionHandle = Arc::new(workdir::LocalWorkdirSession::new( + manifest::Scope::writable(directory.path()).unwrap(), + directory.path().to_path_buf(), + )); + api.workdir_sessions + .lock() + .unwrap() + .insert_attachment(identity.clone(), session); + // An actual session fence prevents cleanup until after cancellation; + // the Runtime receipt is the synchronization event, not an elapsed delay. + let session_guard = current_worker_session_lock(&api, &identity) + .lock_owned() + .await; + let worker = + WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); + let waiter = tokio::spawn(async move { + worker + .stop( + &WorkerOperationContext::Backend, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .await + }); + tokio::time::timeout(std::time::Duration::from_secs(2), stopped.notified()) + .await + .unwrap(); + assert_eq!(tokio::spawn(async { "control" }).await.unwrap(), "control"); + api.store + .db_executor() + .run(|| Ok::<_, Error>(())) + .await + .unwrap(); + waiter.abort(); + assert!(waiter.await.unwrap_err().is_cancelled()); + assert!( + api.workdir_sessions + .lock() + .unwrap() + .attachments + .contains_key(&identity) + ); + drop(session_guard); + tokio::time::timeout( + std::time::Duration::from_secs(2), + api.host_executors.host().unwrap().drain(), + ) + .await + .unwrap() + .unwrap(); + api.store.db_executor().drain().await.unwrap(); + assert!( + !api.workdir_sessions + .lock() + .unwrap() + .attachments + .contains_key(&identity), + "accepted Runtime stop must own session cleanup after the waiter disappears" + ); + } + #[tokio::test] async fn command_session_survives_attachment_refresh_until_worker_revocation() { let directory = tempfile::tempdir().unwrap(); @@ -36165,7 +39283,7 @@ mod tests { cached_status: "connected".to_owned(), timeout: std::time::Duration::from_secs(1), }); - SqliteWorkspaceStore::open(&api.config.database_path) + SqliteWorkspaceStore::open_for_test(&api.config.database_path) .unwrap() .upsert_workspace_runtime_binding( WorkspaceRuntimeBinding { @@ -37253,6 +40371,7 @@ mod tests { Segment::text("Implement Ticket 00001KZ9E0DBS"), ]; let assignment_request = || CreateWorkspaceWorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: "00001KZ9E0DBS".to_string(), operation_id: "worker-spawn:00001KZ9E0DBS:call-1".to_string(), }; @@ -37275,6 +40394,7 @@ mod tests { assert_eq!( assignment, Some(WorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: "00001KZ9E0DBS".to_string(), operation_id: "worker-spawn:00001KZ9E0DBS:call-1".to_string(), }) @@ -37282,6 +40402,7 @@ mod tests { assert!( browser_worker_spawn_policy( Some(CreateWorkspaceWorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: " ".to_string(), operation_id: "operation".to_string(), }), @@ -37348,6 +40469,7 @@ mod tests { }, profile: ProfileSelector::Builtin("builtin:coder".to_string()), ticket_assignment: Some(WorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: ticket.id, operation_id: "planning-spawn".to_string(), }), @@ -37406,8 +40528,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::InProgress.as_str() + .state + .as_str(), + TicketState::Open.as_str() ); let current = fixture .api @@ -37434,7 +40557,7 @@ mod tests { let api = test_api(workspace.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Queued Ticket"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); let ticket = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket.id); @@ -37447,6 +40570,7 @@ mod tests { singleton_key: None, profile: Some("builtin:coder".to_string()), ticket_assignment: Some(CreateWorkspaceWorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: ticket.id.clone(), operation_id: "failed-queued-assignment".to_string(), }), @@ -37462,8 +40586,8 @@ mod tests { assert!(result.is_err()); assert_eq!( - api.authority.ticket(&ticket.id).unwrap().state, - TicketWorkflowState::Queued.as_str() + api.authority.ticket(&ticket.id).unwrap().state.as_str(), + TicketState::Open.as_str() ); assert!( api.store @@ -37666,9 +40790,9 @@ mod tests { async fn workspace_api_does_not_register_a_missing_workspace() { let dir = tempfile::tempdir().unwrap(); let config = test_server_config(dir.path()); - let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); - let error = match WorkspaceApi::new(config, store.clone()).await { + let error = match WorkspaceApi::new_for_test(config, store.clone()).await { Ok(_) => panic!("missing Workspace registration must fail closed"), Err(error) => error, }; @@ -37685,11 +40809,12 @@ mod tests { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let config = test_server_config(workspace.path()); - let store = Arc::new(SqliteWorkspaceStore::open(config.database_path.clone()).unwrap()); + let store = + Arc::new(SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap()); seed_test_registered_workspace(store.as_ref(), &config) .await .unwrap(); - let api = WorkspaceApi::new(config, store).await.unwrap(); + let api = WorkspaceApi::new_for_test(config, store).await.unwrap(); let workspace_id = api.config.workspace_id.clone(); let result = scoped_start_workspace_orchestrator( @@ -37744,7 +40869,7 @@ mod tests { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let config = test_server_config(workspace.path()); - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); let api = WorkspaceApi::new_with_execution_backend( config, Arc::new(store), @@ -37883,7 +41008,7 @@ mod tests { .unwrap(); let controller = RuntimeWorkerRef::new(controller.runtime_id, controller.worker_id); let mut input = ticket::NewTicket::new("Guarded attachment spawn"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); input.targets = vec![ test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), @@ -38098,6 +41223,7 @@ mod tests { .uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers")) // Unit fixture stands below proof middleware, so inject its verified subject. .extension(crate::worker_source::VerifiedRuntimeRequestSource { + authentication_binding_id: "fixture-binding".into(), token_id_hash: None, runtime_id: fixture.controller.runtime_id.clone(), worker_id: Some(fixture.controller.worker_id.clone()), @@ -38178,7 +41304,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); } @@ -38377,7 +41503,7 @@ mod tests { .unwrap(); let controller = RuntimeWorkerRef::new(controller.runtime_id, controller.worker_id); let mut ticket = ticket::NewTicket::new("Strict remote guarded attachment spawn"); - ticket.workflow_state = Some(TicketWorkflowState::Queued); + ticket.workflow_state = Some(TicketState::Open); ticket.targets = vec![ test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), @@ -38422,6 +41548,7 @@ mod tests { .method("POST") .uri(format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers")) .extension(crate::worker_source::VerifiedRuntimeRequestSource { + authentication_binding_id: "fixture-binding".into(), token_id_hash: None, runtime_id: controller.runtime_id.clone(), worker_id: Some(controller.worker_id.clone()), @@ -40456,6 +43583,7 @@ mod tests { actor: None, worker_source: None, runtime_source: Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: created.runtime_id.clone(), worker_id: Some(created.worker_id.clone()), }), @@ -42285,8 +45413,8 @@ mod tests { let restored_config = test_server_config(&root) .with_embedded_runtime_store_root(root.join("restored-embedded-runtime")); let restored_store = - SqliteWorkspaceStore::open(restored_config.database_path.clone()).unwrap(); - let restored = WorkspaceApi::new(restored_config, Arc::new(restored_store)) + SqliteWorkspaceStore::open_for_test(restored_config.database_path.clone()).unwrap(); + let restored = WorkspaceApi::new_for_test(restored_config, Arc::new(restored_store)) .await .unwrap(); assert!( @@ -42734,6 +45862,7 @@ mod tests { // The receiver belongs to one mock execution, not to a Runtime WorkerRecord. // Bound protocol senders become terminal when Stop drops this execution. struct DeterministicWorkerExecution { + dispatch_hook: std::sync::Mutex>>, context: worker_runtime::execution::WorkerExecutionContext, state: std::sync::Mutex, method_tx: tokio::sync::mpsc::Sender, @@ -42753,6 +45882,7 @@ mod tests { let (method_tx, method_rx) = tokio::sync::mpsc::channel(32); let (events, _) = tokio::sync::broadcast::channel(32); Self { + dispatch_hook: std::sync::Mutex::new(None), context, state: std::sync::Mutex::new(protocol::WorkerStateSnapshot::initial()), method_tx, @@ -42776,6 +45906,9 @@ mod tests { Vec, worker_runtime::execution::WorkerExecutionResult, > { + if let Some(hook) = self.dispatch_hook.lock().unwrap().take() { + hook(); + } // Drive the actual mock receiver synchronously; no method is routed // through a WorkerRef lookup after a protocol connection is attached. let mut receiver = self.method_rx.lock().unwrap(); @@ -44220,6 +47353,17 @@ mod tests { .iter() .all(|(_, content)| { !content.contains("[Ticket item checker advisory]") }) ); + // Terminal SQL state is not actual cleanup completion. Keep this + // fixture's Tokio driver alive through accepted Host/cleanup work before + // dropping it, so a pending teardown timer cannot run on a dead runtime. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + api.host_executors.host().unwrap().drain().await.unwrap(); + api.host_executors.cleanup().unwrap().drain().await.unwrap(); + api.worker_projection.shutdown_async().await; + api.store.db_executor().drain().await.unwrap(); + }) + .await + .expect("checker fixture must finish accepted cleanup before runtime shutdown"); } #[tokio::test] @@ -44612,7 +47756,8 @@ mod tests { notification_target: None, limits: crate::backend_job::BackendJobLimits::default(), }; - let store = Arc::new(SqliteWorkspaceStore::open(config.database_path.clone()).unwrap()); + let store = + Arc::new(SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap()); seed_test_registered_workspace(store.as_ref(), &config) .await .unwrap(); @@ -44750,7 +47895,8 @@ mod tests { drop(store); drop(execution); - let restarted_store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let restarted_store = + SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); let restarted_execution = Arc::new(DeterministicExecutionBackend::default()); restarted_execution.accept_restores(); let restarted = WorkspaceApi::new_with_execution_backend( @@ -44899,7 +48045,10 @@ mod tests { protocol::WorkerRunState::Running, )) })); - assert_eq!( + // Cleanup admission precedes its durable claim. The accepted job may + // still be queued, but neither queued nor executing ownership may stop + // this Run before the exact tracked finish marker below is published. + assert!(matches!( api.store .get_backend_job_attempt( TEST_WORKSPACE_ID, @@ -44909,8 +48058,8 @@ mod tests { .unwrap() .unwrap() .worker_cleanup_state, - Some(BackendJobWorkerCleanupState::Executing) - ); + Some(BackendJobWorkerCleanupState::Pending | BackendJobWorkerCleanupState::Executing) + )); execution.publish_worker_state( &worker, @@ -44937,7 +48086,7 @@ mod tests { async fn backend_job_delivery_recovery_replays_same_tracked_notification_id() { let temp = tempfile::tempdir().unwrap(); let config = test_server_config(temp.path()); - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); let execution = Arc::new(DeterministicExecutionBackend::default()); let api = WorkspaceApi::new_with_execution_backend( config.clone(), @@ -45512,7 +48661,7 @@ mod tests { if let Some(parent) = config.database_path.parent() { fs::create_dir_all(parent).unwrap(); } - SqliteWorkspaceStore::open(&config.database_path).unwrap() + SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap() } #[tokio::test] @@ -45573,7 +48722,7 @@ mod tests { .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 store = Arc::new(SqliteWorkspaceStore::open_for_test(&template.database_path).unwrap()); let app = build_workspace_server_router(template, store) .await .unwrap(); @@ -45656,7 +48805,7 @@ mod tests { async fn repository_free_workspace_can_be_created_selected_and_used_then_register_repository() { let temp = tempfile::tempdir().unwrap(); let config = test_server_config(temp.path()); - let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); let owner = seed_test_api_token(store.as_ref(), "empty-owner"); let foreign = seed_test_api_token(store.as_ref(), "empty-foreign"); let app = build_workspace_server_router(config.clone(), store.clone()) @@ -45908,7 +49057,7 @@ mod tests { .. } = &config.auth; let expected_origin = expected_origin.clone(); - let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); store .upsert_account(&AccountRecord { account_id: "account-auth".to_owned(), @@ -47185,7 +50334,7 @@ mod tests { .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 store = Arc::new(SqliteWorkspaceStore::open_for_test(&template.database_path).unwrap()); let token = seed_test_api_token(store.as_ref(), "two-workspaces"); let catalog = WorkspaceCatalogService::new( store.clone(), @@ -47326,7 +50475,7 @@ mod tests { let repository = dir.path().join("repository"); std::fs::create_dir_all(repository.join(".git")).unwrap(); let template = test_server_config(dir.path()); - let store = Arc::new(SqliteWorkspaceStore::open(&template.database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&template.database_path).unwrap()); let app = build_workspace_server_router(template, store) .await .unwrap(); @@ -47395,7 +50544,7 @@ mod tests { async fn memory_consolidation_backlog_ignores_legacy_filesystem_staging() { let dir = tempfile::tempdir().unwrap(); let config = test_server_config(dir.path()); - let store = SqliteWorkspaceStore::open(&config.database_path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap(); let api = WorkspaceApi::new_with_execution_backend( config, Arc::new(store), @@ -47428,7 +50577,7 @@ mod tests { async fn memory_consolidation_reuses_existing_idle_worker() { let dir = tempfile::tempdir().unwrap(); let config = test_server_config(dir.path()); - let store = SqliteWorkspaceStore::open(&config.database_path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap(); let api = WorkspaceApi::new_with_execution_backend( config, Arc::new(store), @@ -47618,7 +50767,7 @@ mod tests { let ready = backend .mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request.clone()) .unwrap(); - assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(ready.meta.workflow_state, TicketState::Open); assert_eq!( ready.meta.targets, vec![test_ticket_target( @@ -47664,7 +50813,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(edited.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(edited.meta.workflow_state, TicketState::Open); assert_eq!( edited.meta.targets[0].ref_selector.as_deref(), Some("other") @@ -47672,17 +50821,20 @@ mod tests { let mut missing = ticket::NewTicket::new("Missing target"); set_test_ticket_target(&mut missing, "unknown", "develop"); assert!(backend.create(missing).is_err()); - backend - .set_workflow_state( - ticket_ref.id.into(), - TicketStateChange::new( - "ready", - "inprogress", - "Direct progress judgment", - "No Worker start implied", + let observed = backend.show(ticket_ref.id.clone().into()).unwrap(); + let closed = backend + .update_case( + &ticket_ref.id, + case_test_request( + &observed, + "direct-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, ), ) .unwrap(); + assert_eq!(closed.meta.workflow_state, TicketState::Closed); } #[tokio::test] @@ -47717,7 +50869,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(ready.meta.workflow_state, TicketState::Open); assert_eq!(ready.meta.targets[0].ref_selector, None); assert!( validated_ticket_resource_targets(&api, &reference.id).unwrap()[0] @@ -47735,7 +50887,7 @@ mod tests { let writable_repository_id = test_repository_id(&api); let docs_repository_id = test_repository_id_by_key(&api, "docs"); let mut input = ticket::NewTicket::new("Read-only MR target"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); input.targets = vec![ test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), @@ -47801,6 +50953,42 @@ mod tests { } } + async fn author_notification_test_answer(api: &WorkspaceApi, ticket_id: &str, key: &str) { + let current = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket_id) + .unwrap() + .remove(0); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri(format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket_id}/assignment-answer" + )) + .header( + "authorization", + format!("Bearer api-token-{TEST_WORKSPACE_ID}"), + ) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + json!({ + "assignment_id": current.assignment_id, + "expected_last_event_id": current.last_event_id.unwrap(), + "operation_key": key, + "body": "Explicit continuing input" + }) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, StatusCode::OK, "{}", String::from_utf8_lossy(&body)); + } + #[tokio::test] async fn orchestrator_ticket_notifications_project_authoritative_post_mutation_state() { let dir = tempfile::tempdir().unwrap(); @@ -47926,6 +51114,7 @@ mod tests { accept_queued_ticket_after_worker_spawn( &api, &crate::hosts::WorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: ticket.id.clone(), operation_id: "notification-source-assignment-operation".to_string(), }, @@ -47955,27 +51144,61 @@ mod tests { .unwrap(); } + assert_eq!( + execution.take_inputs().len(), + 1, + "ordinary history must not create unfenced continuing input" + ); + seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + author_notification_test_answer(&api, &ticket.id, "notification-explicit-answer").await; let inputs = execution.take_inputs(); - let expected_states = [ - ("ready", "queued"), - ("inprogress", "inprogress"), - ("inprogress", "inprogress"), - ("inprogress", "inprogress"), - ]; - assert_eq!(inputs.len(), expected_states.len()); - for ((recipient, content), (previous_state, current_state)) in - inputs.iter().zip(expected_states) - { + assert_eq!(inputs.len(), 1, "one fenced continuing answer"); + let unique = inputs + .iter() + .map(|(_, content)| content.clone()) + .collect::>(); + assert_eq!( + unique.len(), + inputs.len(), + "each committed input has its own delivery identity" + ); + for (recipient, content) in &inputs { assert_eq!(recipient.worker_id.to_string(), orchestrator.worker_id); - assert!(!content.contains(&ticket.id)); - assert_eq!( - content, - &ticket_notification_content( - ticket.resource_key.as_deref().unwrap(), - previous_state, - current_state, - ) - ); + assert!(content.starts_with("Assigned Tickets have unreceived input:")); + assert!(content.contains(ticket.resource_key.as_deref().unwrap())); + assert!(content.contains("Delegation:")); + assert!(content.contains("input:")); + } + let tracking = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket.id) + .unwrap(); + assert_eq!( + tracking[0].tracking.as_ref().unwrap().status, + ticket::TicketAssignmentTrackingStatus::Waiting + ); + assert!( + api.store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) + .unwrap() + .is_empty() + ); + } + + fn case_test_request( + before: &ticket::Ticket, + key: &str, + action: ticket::TicketCaseAction, + ) -> ticket::TicketCaseUpdate { + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(before), + expected_state: before.meta.workflow_state, + expected_closure_operation_key: before.closure_operation_key.clone(), + operation_key: key.into(), + action, + explanation: "User case judgment".into(), + references: vec![], + author: None, } } @@ -48027,20 +51250,25 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Assigned investigation"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketState::Open); let ticket = backend.create(input.clone()).unwrap(); let _owner = seed_live_test_coder_assignment(&api, &ticket.id, "owner").await; let other_ticket = backend.create(input).unwrap(); let other = seed_live_test_coder_assignment(&api, &other_ticket.id, "other").await; let before = backend.show(ticket.id.clone().into()).unwrap(); - let request = server_api::CompleteTicketRequest { + let request = ticket::TicketCaseUpdate { + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + expected_closure_operation_key: None, + author: None, operation_key: "forged".into(), expected_content_digest: ticket::ticket_content_digest(&before), - expected_state: TicketWorkflowState::InProgress, - reason: "Not my task".into(), + expected_state: TicketState::Open, + explanation: "Not my task".into(), references: Vec::new(), }; - let response = scoped_complete_ticket( + let response = scoped_ticket_case_update( State(api.clone()), ticket_check_headers(&other), AxumPath((TEST_WORKSPACE_ID.into(), ticket.id.clone())), @@ -48050,26 +51278,6 @@ mod tests { .unwrap_err() .into_response(); assert_eq!(response.status(), StatusCode::FORBIDDEN); - let response = scoped_transition_ticket_state( - State(api.clone()), - ticket_check_headers(&other), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.into(), - id: ticket.id.clone(), - }), - Json(server_api::BrowserTransitionTicketStateRequest { - state: server_api::BrowserTicketWorkflowState::Done, - expected_state: server_api::BrowserTicketWorkflowState::Inprogress, - operation_key: "browser-forged".into(), - expected_content_digest: ticket::ticket_content_digest(&before), - reason: "Not human authority".into(), - body: None, - }), - ) - .await - .unwrap_err() - .into_response(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(backend.show(ticket.id.into()).unwrap(), before); } @@ -48079,20 +51287,25 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Research complete without repository"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketState::Open); let reference = backend.create(input).unwrap(); let coder = seed_live_test_coder_assignment(&api, &reference.id, "completed-coder").await; let before = backend.show(reference.id.clone().into()).unwrap(); - let request = server_api::CompleteTicketRequest { + let request = ticket::TicketCaseUpdate { + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + expected_closure_operation_key: None, + author: None, operation_key: "research-done".into(), expected_content_digest: ticket::ticket_content_digest(&before), - expected_state: TicketWorkflowState::InProgress, - reason: "## 完了理由\n\n日本語の長い検証結果を省略せず記録する。\n\n" + expected_state: TicketState::Open, + explanation: "## 完了理由\n\n日本語の長い検証結果を省略せず記録する。\n\n" .repeat(1024), references: Vec::new(), }; let call = |request| { - scoped_complete_ticket( + scoped_ticket_case_update( State(api.clone()), ticket_check_headers(&coder), AxumPath((TEST_WORKSPACE_ID.into(), reference.id.clone())), @@ -48100,10 +51313,10 @@ mod tests { ) }; let Json(completed) = call(request.clone()).await.unwrap(); - assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done); + assert_eq!(completed.meta.workflow_state, TicketState::Closed); let event = completed.events.last().unwrap(); - assert_eq!(event.reason.as_deref(), Some(request.reason.as_str())); - assert_eq!(event.body.as_str(), request.reason); + assert_eq!(event.reason.as_deref(), Some(request.explanation.as_str())); + assert_eq!(event.body.as_str(), request.explanation); assert_eq!( backend.show(reference.id.clone().into()).unwrap(), completed @@ -48120,9 +51333,9 @@ mod tests { for field in ["reason", "content_digest", "state", "references"] { let mut changed = request.clone(); match field { - "reason" => changed.reason = "different judgment".into(), + "reason" => changed.explanation = "different judgment".into(), "content_digest" => changed.expected_content_digest = "stale".into(), - "state" => changed.expected_state = TicketWorkflowState::Planning, + "state" => changed.expected_state = TicketState::Closed, "references" => changed.references.push(ticket::TicketReference { kind: "document".into(), target: "other".into(), @@ -48153,7 +51366,7 @@ mod tests { ); let mut fresh = request; fresh.operation_key = "unassigned-new-operation".into(); - fresh.expected_state = TicketWorkflowState::Done; + fresh.expected_state = TicketState::Closed; assert!(call(fresh).await.is_err()); } @@ -48162,11 +51375,13 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (api, execution) = test_api_with_recording_backend(dir.path()).await; let mut input = ticket::NewTicket::new("Close and reopen responsibility"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut input, "test-repository", "develop"); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); - let old_coder = seed_live_test_coder_assignment(&api, &ticket.id, "old-coder").await; + // The retained delegation precedes actual Coder work. A new Assign + // during that work would be a different operation and must be refused. assign_test_orchestrator(&api, &ticket.id); + let old_coder = seed_live_test_coder_assignment(&api, &ticket.id, "old-coder").await; let old_orchestrator_id = format!("orchestrator-{}", ticket.id); let Json(started) = scoped_start_workspace_orchestrator( State(api.clone()), @@ -48179,35 +51394,38 @@ mod tests { let orchestrator = started.worker.unwrap(); let orchestrator = RuntimeWorkerRef::new(orchestrator.runtime_id, orchestrator.worker_id); execution.take_inputs(); - let status = scoped_close_ticket_record( + let before = browser_ticket_backend(&api) + .unwrap() + .show(ticket.id.clone().into()) + .unwrap(); + let Json(closed) = scoped_ticket_case_update( State(api.clone()), - AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())), HeaderMap::new(), - Json(ticket::TicketCompletion { - operation_key: "normal-close".into(), - expected_content_digest: api.authority.ticket(&ticket.id).unwrap().content_digest, - expected_state: TicketWorkflowState::InProgress, - reason: "Normal close ends implementation work".into(), - references: Vec::new(), - author: None, - }), + AxumPath((TEST_WORKSPACE_ID.into(), ticket.id.clone())), + Json(case_test_request( + &before, + "normal-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + )), ) .await .unwrap(); - assert_eq!(status, StatusCode::NO_CONTENT); assert!(execution.take_inputs().is_empty()); - - for state in ["closed", "planning"] { - if state == "planning" { - // No public reopen operation exists. Seed a reopened authority - // record after normal API close; the durable release must survive. - let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); - crate::store::configure_sqlite(&conn).unwrap(); - conn.execute( - "UPDATE typed_tickets SET workflow_state = 'planning', status = 'open' - WHERE workspace_id = ?1 AND ticket_id = ?2", - rusqlite::params![TEST_WORKSPACE_ID, ticket.id], + for state in ["closed", "open"] { + if state == "open" { + let _ = scoped_ticket_case_update( + State(api.clone()), + HeaderMap::new(), + AxumPath((TEST_WORKSPACE_ID.into(), ticket.id.clone())), + Json(case_test_request( + &closed, + "reopen", + ticket::TicketCaseAction::Reopen, + )), ) + .await .unwrap(); } let path = || { @@ -48217,7 +51435,7 @@ mod tests { }) }; let Json(detail) = scoped_get_ticket(State(api.clone()), path()).await.unwrap(); - assert_eq!(detail.state, state); + assert_eq!(detail.state.as_str(), state); assert_eq!(detail.current_worker.unwrap().assignment_id, "old-coder"); assert!( detail @@ -48225,9 +51443,9 @@ mod tests { .iter() .any(|assignment| assignment.assignment_id == old_orchestrator_id) ); - assert_eq!( + assert!( detail.action_eligibility.can_assign_orchestrator, - state == "planning" + "retained responsibility alone is not an active assignment: state={state}" ); let Json(assignments) = scoped_list_ticket_assignments(State(api.clone()), path()) .await @@ -48332,6 +51550,7 @@ mod tests { "orchestrator".to_string(), )), Json(server_api::SetTicketRoleAssignmentRequest { + orchestrator_assignment_id: None, workdir_bindings: Vec::new(), operation_id: "fresh-orchestrator-cycle".to_string(), principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { @@ -48366,7 +51585,7 @@ mod tests { .unwrap() .show(TicketIdOrSlug::Id(ticket.id.clone())) .unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(queued.meta.workflow_state, TicketState::Open); assert_eq!( queued .events @@ -48378,10 +51597,12 @@ mod tests { let inputs = execution.take_inputs(); assert_eq!(inputs.len(), 1); assert_eq!(inputs[0].0.worker_id.to_string(), orchestrator.worker_id); - assert_eq!( - inputs[0].1, - ticket_notification_content(ticket.resource_key.as_deref().unwrap(), "ready", "queued") + assert!( + inputs[0] + .1 + .starts_with("Assigned Tickets have unreceived input:") ); + assert!(inputs[0].1.contains(&fresh_id)); assert!( api.store .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) @@ -48477,7 +51698,7 @@ mod tests { } sync_runtime_worker_workdir_attachments(&api, &worker).unwrap(); let mut input = ticket::NewTicket::new("Manual multi-target Coder"); - input.workflow_state = Some(TicketWorkflowState::Ready); + input.workflow_state = Some(TicketState::Open); input.targets = vec![ test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), @@ -48503,6 +51724,7 @@ mod tests { operation_id: &str, ) -> server_api::SetTicketRoleAssignmentRequest { server_api::SetTicketRoleAssignmentRequest { + orchestrator_assignment_id: None, workdir_bindings: fixture .api .store @@ -48556,8 +51778,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::InProgress.as_str() + .state + .as_str(), + TicketState::Open.as_str() ); let links = fixture .api @@ -48790,8 +52013,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::Ready.as_str(), + .state + .as_str(), + TicketState::Open.as_str(), "case={case}" ); assert!( @@ -48861,8 +52085,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::Ready.as_str() + .state + .as_str(), + TicketState::Open.as_str() ); assert!( fixture @@ -48908,6 +52133,7 @@ mod tests { .create(ticket::NewTicket::new("Idempotent assignment")) .unwrap(); let request = || server_api::SetTicketRoleAssignmentRequest { + orchestrator_assignment_id: None, workdir_bindings: Vec::new(), operation_id: "same-role-operation".to_string(), principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { @@ -48964,6 +52190,7 @@ mod tests { "worker".to_string(), )), Json(server_api::SetTicketRoleAssignmentRequest { + orchestrator_assignment_id: None, workdir_bindings: Vec::new(), operation_id: "invalid-workspace-agent-coder".to_string(), principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { @@ -48985,6 +52212,9 @@ mod tests { assigned_by: "test-user".to_string(), assigned_at: TEST_CREATED_AT.to_string(), }; + // Seed an existing delegation before starting the Worker; both may + // remain current, but a fresh delegation during Worker work is guarded. + assign_test_orchestrator(&api, &ticket_id); api.store .set_current_ticket_worker_assignment( &assignment, @@ -48994,7 +52224,6 @@ mod tests { false, ) .unwrap(); - assign_test_orchestrator(&api, &ticket_id); let path = || ScopedRecordPath { workspace_id: TEST_WORKSPACE_ID.to_string(), id: ticket_id.clone(), @@ -49018,7 +52247,7 @@ mod tests { .iter() .any(|blocker| blocker.contains("Orchestrator and manual Worker")) ); - assert!(!detail.action_eligibility.can_assign_orchestrator); + assert!(detail.action_eligibility.can_assign_orchestrator); assert!(!detail.action_eligibility.can_start_manual_worker); let stale = scoped_clear_ticket_assignment( @@ -49128,7 +52357,7 @@ mod tests { .unwrap(); let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Implementation cancellation"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketState::Open); let ticket = backend.create(input).unwrap(); api.store .set_current_ticket_worker_assignment( @@ -49164,7 +52393,7 @@ mod tests { scoped_cancel_ticket_implementation(State(api.clone()), path(), request()) .await .unwrap(); - assert_eq!(cancelled.state, TicketWorkflowState::Ready.as_str()); + assert_eq!(cancelled.state.as_str(), TicketState::Open.as_str()); assert!(cancelled.current_worker.is_none()); assert!( !cancelled @@ -49177,7 +52406,7 @@ mod tests { let Json(replayed) = scoped_cancel_ticket_implementation(State(api), path(), request()) .await .unwrap(); - assert_eq!(replayed.state, TicketWorkflowState::Ready.as_str()); + assert_eq!(replayed.state.as_str(), TicketState::Open.as_str()); } #[tokio::test] @@ -49489,46 +52718,37 @@ mod tests { } #[tokio::test] - async fn browser_progress_decisions_allow_direct_completion_and_reopen_with_cas() { + async fn browser_case_decisions_close_correct_and_reopen_without_assets() { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let reference = backend .create(ticket::NewTicket::new("No asset needed")) .unwrap(); - for (index, state) in [ - server_api::BrowserTicketWorkflowState::Inprogress, - server_api::BrowserTicketWorkflowState::Done, - server_api::BrowserTicketWorkflowState::Closed, - server_api::BrowserTicketWorkflowState::Planning, - ] - .into_iter() - .enumerate() - { + let actions = [ + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::Duplicate, + }, + ticket::TicketCaseAction::Reopen, + ]; + for (index, action) in actions.into_iter().enumerate() { let before = backend.show(reference.id.clone().into()).unwrap(); - let expected_state = - serde_json::from_value(serde_json::json!(before.meta.workflow_state.as_str())) - .unwrap(); - let request = server_api::BrowserTransitionTicketStateRequest { - state: state.clone(), - operation_key: format!("decision-{index}"), - expected_content_digest: ticket::ticket_content_digest(&before), - expected_state, - reason: "Human progress judgment".into(), - body: None, - }; - let Json(after) = scoped_transition_ticket_state( + let Json(after) = scoped_ticket_case_update( State(api.clone()), HeaderMap::new(), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.into(), - id: reference.id.clone(), - }), - Json(request), + AxumPath((TEST_WORKSPACE_ID.into(), reference.id.clone())), + Json(case_test_request( + &before, + &format!("decision-{index}"), + action, + )), ) .await .unwrap(); - assert_eq!(after.state, browser_ticket_workflow_state(state).as_str()); + assert_eq!(after.case_history.len(), index + 1); assert!( api.store .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &reference.id) @@ -49537,27 +52757,31 @@ mod tests { ); } let before = backend.show(reference.id.clone().into()).unwrap(); - let response = scoped_transition_ticket_state( + assert_eq!(before.meta.workflow_state, TicketState::Open); + assert_eq!( + before.case_history[0].action, + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned + } + ); + let mut stale = case_test_request( + &before, + "stale", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + stale.expected_content_digest = "stale-content-digest".into(); + let response = scoped_ticket_case_update( State(api), HeaderMap::new(), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.into(), - id: reference.id, - }), - Json(server_api::BrowserTransitionTicketStateRequest { - state: server_api::BrowserTicketWorkflowState::Done, - operation_key: "stale".into(), - expected_content_digest: "stale-content-digest".into(), - expected_state: server_api::BrowserTicketWorkflowState::Planning, - reason: "Stale decision".into(), - body: None, - }), + AxumPath((TEST_WORKSPACE_ID.into(), reference.id)), + Json(stale), ) .await .unwrap_err() .into_response(); assert_eq!(response.status(), StatusCode::CONFLICT); - assert_eq!(before.meta.workflow_state, TicketWorkflowState::Planning); } #[tokio::test] @@ -49577,7 +52801,7 @@ mod tests { .unwrap(); assert_eq!(outcome.queued_tickets, vec![ticket.id.clone()]); let queued = backend.show(ticket.id.clone().into()).unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(queued.meta.workflow_state, TicketState::Open); assert!( !queued .events @@ -49594,7 +52818,7 @@ mod tests { ); // If an explicit routing identity exists, retain its fence for later claim. let mut input = ticket::NewTicket::new("Fenced request"); - input.workflow_state = Some(TicketWorkflowState::Ready); + input.workflow_state = Some(TicketState::Open); let fenced = backend.create(input).unwrap(); assign_test_orchestrator(&api, &fenced.id); let _ = scoped_queue_ticket_record( @@ -49620,11 +52844,11 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut first_input = ticket::NewTicket::new("First cycle Ticket"); - first_input.workflow_state = Some(TicketWorkflowState::Ready); + first_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut first_input, "test-repository", "develop"); let first = backend.create(first_input).unwrap(); let mut second_input = ticket::NewTicket::new("Second cycle Ticket"); - second_input.workflow_state = Some(TicketWorkflowState::Ready); + second_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut second_input, "test-repository", "develop"); let second = backend.create(second_input).unwrap(); for (ticket_id, target) in [ @@ -49661,7 +52885,7 @@ mod tests { assert_eq!(outcome.queued_tickets, vec![first.id.clone()]); assert_eq!( backend.show(second.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); assert!( backend @@ -49679,11 +52903,11 @@ mod tests { let api = test_api_with_remote_repository(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut dependency_input = ticket::NewTicket::new("Remote target dependency"); - dependency_input.workflow_state = Some(TicketWorkflowState::Ready); + dependency_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut dependency_input, "test-repository", "remote-feature"); let dependency = backend.create(dependency_input).unwrap(); let mut root_input = ticket::NewTicket::new("Queue root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); + root_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut root_input, "test-repository", "develop"); let root = backend.create(root_input).unwrap(); backend @@ -49713,7 +52937,7 @@ mod tests { assert_eq!(outcome.queued_tickets, vec![root.id.clone()]); assert_eq!( backend.show(root.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); assert_eq!( backend @@ -49721,7 +52945,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); } @@ -49766,7 +52990,7 @@ mod tests { .unwrap(); let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Queued notification"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); let ticket_ref = backend.create(input).unwrap(); let mut headers = HeaderMap::new(); headers.insert( @@ -49847,7 +53071,7 @@ mod tests { let _ = execution.take_inputs(); let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Queued notification"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); let ticket_ref = backend.create(input).unwrap(); assign_test_orchestrator(&api, ticket_ref.id.as_str()); let missing_recipient = @@ -49911,8 +53135,8 @@ mod tests { .unwrap(); assert_eq!( - api.authority.ticket(&ticket_ref.id).unwrap().state, - TicketWorkflowState::Queued.as_str() + api.authority.ticket(&ticket_ref.id).unwrap().state.as_str(), + TicketState::Open.as_str() ); let warnings = TICKET_NOTIFICATION_DELIVERY_WARNING_CAPTURE .lock() @@ -49926,7 +53150,7 @@ mod tests { assert_eq!(warning.level, "warning"); assert_eq!(warning.event, "ticket_notification_delivery_failed"); assert_eq!(warning.workspace_id, TEST_WORKSPACE_ID); - assert_eq!(warning.current_state, TicketWorkflowState::Queued.as_str()); + assert_eq!(warning.current_state, TicketState::Open.as_str()); assert_eq!(warning.recipient_runtime_id, missing_recipient.runtime_id); assert_eq!(warning.recipient_worker_id, missing_recipient.worker_id); assert_eq!(warning.error_category, "unknown_worker"); @@ -49934,20 +53158,24 @@ mod tests { assert!(!serialized.contains("queued update")); assert!(!serialized.contains("Ticket notification:")); + seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + author_notification_test_answer(&api, &ticket_ref.id, "notification-saved-answer").await; let notifications = execution.take_inputs(); - assert_eq!(notifications.len(), 1); + assert_eq!( + notifications.len(), + 2, + "initial delegation plus fenced answer; ordinary comment is history" + ); assert_eq!( notifications[0].0.worker_id.to_string(), orchestrator.worker_id ); - assert_eq!( - notifications[0].1, - ticket_notification_content( - ticket_ref.resource_key.as_deref().unwrap(), - TicketWorkflowState::Queued.as_str(), - TicketWorkflowState::Queued.as_str() - ) + assert!( + notifications + .iter() + .all(|(_, content)| content.starts_with("Assigned Tickets have unreceived input:")) ); + assert_ne!(notifications[0].1, notifications[1].1); execution.reject_inputs("sensitive fake Runtime transport detail"); TICKET_NOTIFICATION_DELIVERY_WARNING_CAPTURE @@ -49978,9 +53206,10 @@ mod tests { .unwrap(); assert_eq!( - api.authority.ticket(&ticket_ref.id).unwrap().state, - TicketWorkflowState::Queued.as_str() + api.authority.ticket(&ticket_ref.id).unwrap().state.as_str(), + TicketState::Open.as_str() ); + author_notification_test_answer(&api, &ticket_ref.id, "notification-failed-answer").await; let warnings = TICKET_NOTIFICATION_DELIVERY_WARNING_CAPTURE .lock() .unwrap_or_else(|poisoned| poisoned.into_inner()) @@ -49988,7 +53217,11 @@ mod tests { .filter(|warning| warning.ticket_id == ticket_ref.id) .cloned() .collect::>(); - assert_eq!(warnings.len(), 2); + assert_eq!( + warnings.len(), + 3, + "one missing Worker and two bounded Orchestrator delivery attempts" + ); assert!(warnings.iter().any(|warning| { warning.recipient_worker_id == missing_recipient.worker_id && warning.error_category == "unknown_worker" @@ -50002,953 +53235,15 @@ mod tests { assert!(!serialized.contains("Ticket notification:")); assert!(!serialized.contains("sensitive fake Runtime transport detail")); let attempts = execution.take_inputs(); - assert_eq!(attempts.len(), 1); + assert_eq!(attempts.len(), 2); + assert_eq!( + attempts[0].1, attempts[1].1, + "bounded retry retains immutable request payload" + ); assert_eq!(attempts[0].0.worker_id.to_string(), orchestrator.worker_id); } - async fn attention_test_api() -> ( - tempfile::TempDir, - WorkspaceApi, - Arc, - ) { - let dir = tempfile::tempdir().unwrap(); - init_clean_git_workspace(dir.path()); - let (api, execution) = test_api_with_recording_backend(dir.path()).await; - let mut input = ticket::NewTicket::new("Attention backlog"); - input.workflow_state = Some(TicketWorkflowState::Queued); - set_test_ticket_target(&mut input, "test-repository", "HEAD"); - let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); - assign_test_orchestrator(&api, &ticket.id); - (dir, api, execution) - } - - async fn start_attention_test_orchestrator( - api: &WorkspaceApi, - ) -> BrowserWorkspaceOrchestratorResponse { - scoped_start_workspace_orchestrator( - State(api.clone()), - AxumPath(ScopedWorkspacePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - }), - ) - .await - .unwrap() - .0 - } - - #[tokio::test] - async fn orchestrator_attention_parallel_start_and_idle_observation_send_once() { - let (_dir, api, execution) = attention_test_api().await; - let reentrant_api = api.clone(); - execution.before_input_returns(move |worker| { - use protocol::subscription::SubscriptionWorkerState as S; - // The callback is inside Runtime send_input. Neither initial Idle nor - // a real turn end may deadlock or issue another in-flight delivery. - maybe_dispatch_orchestrator_turn_end( - &reentrant_api, - &worker.worker_id.to_string(), - None, - S::Idle, - ); - maybe_dispatch_orchestrator_turn_end( - &reentrant_api, - &worker.worker_id.to_string(), - Some(S::Running), - S::Idle, - ); - }); - let barrier = Arc::new(std::sync::Barrier::new(8)); - let results = std::thread::scope(|scope| { - let joins = (0..8) - .map(|_| { - let api = api.clone(); - let barrier = barrier.clone(); - scope.spawn(move || { - barrier.wait(); - futures::executor::block_on(start_attention_test_orchestrator(&api)) - }) - }) - .collect::>(); - joins - .into_iter() - .map(|j| j.join().unwrap()) - .collect::>() - }); - assert_eq!( - results - .iter() - .filter(|r| r.disposition == "created") - .count(), - 1 - ); - assert_eq!( - results - .iter() - .filter(|r| r.disposition == "existing") - .count(), - 7 - ); - assert_eq!(execution.take_inputs().len(), 1); - assert_eq!(execution.take_input_request_ids().len(), 1); - assert_eq!( - start_attention_test_orchestrator(&api).await.disposition, - "existing" - ); - assert!(execution.take_inputs().is_empty()); - } - - #[tokio::test] - async fn orchestrator_attention_barrier_callers_share_unsent_reservation() { - let (_dir, api, execution) = attention_test_api().await; - let started = start_attention_test_orchestrator(&api).await; - let worker = RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - started.worker.unwrap().worker_id, - ); - let state = orchestrator_attention_state(&api, &worker); - state.lock().unwrap().accepted = None; - execution.take_inputs(); - execution.take_input_request_ids(); - let barrier = Arc::new(std::sync::Barrier::new(8)); - let (entered_tx, entered_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - execution.before_input_returns(move |_| { - entered_tx.send(()).unwrap(); - release_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .unwrap(); - }); - std::thread::scope(|scope| { - let joins = (0..8) - .map(|_| { - let api = api.clone(); - let worker = worker.clone(); - let barrier = barrier.clone(); - scope.spawn(move || { - barrier.wait(); - dispatch_orchestrator_queue_attention(&api, &worker, false); - }) - }) - .collect::>(); - entered_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .unwrap(); - assert!(state.lock().unwrap().sending); - assert!(state.lock().unwrap().accepted.is_none()); - // The success record does not exist yet. A ninth caller must still - // return immediately rather than compare/send twice or wait on Runtime. - dispatch_orchestrator_queue_attention(&api, &worker, false); - release_tx.send(()).unwrap(); - for join in joins { - join.join().unwrap(); - } - }); - assert_eq!(execution.take_inputs().len(), 1); - assert_eq!(execution.take_input_request_ids().len(), 1); - assert!(!state.lock().unwrap().sending); - assert!(state.lock().unwrap().accepted.is_some()); - } - - #[tokio::test] - async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_comparison() { - for pause_before_comparison in [true, false] { - let (_dir, api, execution) = attention_test_api().await; - let started = start_attention_test_orchestrator(&api).await; - let worker = RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - started.worker.unwrap().worker_id, - ); - let state = orchestrator_attention_state(&api, &worker); - let old_id = state - .lock() - .unwrap() - .accepted - .as_ref() - .unwrap() - .request_id - .clone(); - execution.take_inputs(); - execution.take_input_request_ids(); - api.runtime - .stop_worker( - &worker, - WorkerLifecycleRequest { - reason: None, - ticket_assignment: None, - }, - ) - .unwrap(); - execution.accept_restores(); - let (entered_tx, entered_rx) = std::sync::mpsc::channel(); - let (release_tx, release_rx) = std::sync::mpsc::channel(); - std::thread::scope(|scope| { - let api_for_caller = api.clone(); - let worker_for_caller = worker.clone(); - let caller = scope.spawn(move || { - let mut first = true; - with_orchestrator_attention_reservation( - &api_for_caller, - &worker_for_caller, - false, - |state, pending| { - if first && pause_before_comparison { - entered_tx.send(()).unwrap(); - release_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .unwrap(); - } - check_orchestrator_queue_attention( - &api_for_caller, - &worker_for_caller, - state, - pending, - ); - if first && !pause_before_comparison { - // Old fingerprint matched: check returned without sending, - // but the exact same production reservation still exists. - assert_eq!( - state.lock().unwrap().accepted.as_ref().unwrap().request_id, - old_id - ); - entered_tx.send(()).unwrap(); - release_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .unwrap(); - } - first = false; - }, - ); - }); - entered_rx - .recv_timeout(std::time::Duration::from_secs(5)) - .unwrap(); - assert!(state.lock().unwrap().sending); - let restored = futures::executor::block_on(start_attention_test_orchestrator(&api)); - assert_eq!(restored.disposition, "restored"); - let deferred = state.lock().unwrap().restore_recheck; - let old_retired = state.lock().unwrap().accepted.is_none(); - let inputs_before_release = execution.take_inputs(); - release_tx.send(()).unwrap(); - caller.join().unwrap(); - assert!(deferred); - assert!(old_retired); - assert!(inputs_before_release.is_empty()); - }); - assert_eq!(execution.take_inputs().len(), 1); - assert_eq!(execution.take_input_request_ids().len(), 1); - let state = state.lock().unwrap(); - assert!(state.accepted.is_some()); - assert!(!state.sending); - assert!(!state.restore_recheck); - } - } - - #[tokio::test] - async fn orchestrator_attention_restore_keeps_pending_unknown_delivery_and_id() { - let (_dir, api, execution) = attention_test_api().await; - execution.reject_inputs("unknown acceptance response"); - let started = start_attention_test_orchestrator(&api).await; - let worker = RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - started.worker.unwrap().worker_id, - ); - let state = orchestrator_attention_state(&api, &worker); - let pending = state.lock().unwrap().pending.clone().unwrap(); - execution.take_inputs(); - execution.take_input_request_ids(); - api.runtime - .stop_worker( - &worker, - WorkerLifecycleRequest { - reason: None, - ticket_assignment: None, - }, - ) - .unwrap(); - execution.accept_restores(); - *execution.input_failure.lock().unwrap() = None; - let restored = start_attention_test_orchestrator(&api).await; - assert_eq!(restored.disposition, "restored"); - let attempts = execution.take_inputs(); - assert_eq!(attempts.len(), 1); - assert_eq!(attempts[0].1, pending.content); - assert_eq!( - execution.take_input_request_ids(), - vec![Some(pending.request_id.clone())] - ); - assert!(state.lock().unwrap().pending.is_none()); - assert_eq!( - state.lock().unwrap().accepted.as_ref().unwrap().request_id, - pending.request_id - ); - } - - #[tokio::test] - async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() { - let (_dir, api, execution) = attention_test_api().await; - execution.reject_inputs("accepted response lost (unknown transport outcome)"); - let started = start_attention_test_orchestrator(&api).await; - let worker = RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - started.worker.unwrap().worker_id, - ); - let state = orchestrator_attention_state(&api, &worker); - assert!(state.lock().unwrap().accepted.is_none()); - let pending = state.lock().unwrap().pending.clone().unwrap(); - let attempts = execution.take_inputs(); - let ids = execution.take_input_request_ids(); - assert_eq!(attempts.len(), 2); - assert_eq!(ids, vec![Some(pending.request_id.clone()); 2]); - // Change the backlog before retry: uncertain acceptance must never reuse - // the ID for a newly rendered payload. - let mut next = ticket::NewTicket::new("Changed backlog"); - next.workflow_state = Some(TicketWorkflowState::Queued); - set_test_ticket_target(&mut next, "test-repository", "HEAD"); - let next = browser_ticket_backend(&api).unwrap().create(next).unwrap(); - assign_test_orchestrator(&api, &next.id); - *execution.input_failure.lock().unwrap() = None; - assert_eq!( - start_attention_test_orchestrator(&api).await.disposition, - "existing" - ); - let retry = execution.take_inputs(); - assert_eq!(retry.len(), 1); - assert_eq!(retry[0].1, pending.content); - assert_eq!( - execution.take_input_request_ids(), - vec![Some(pending.request_id)] - ); - assert!(state.lock().unwrap().pending.is_none()); - assert!(state.lock().unwrap().accepted.is_some()); - use protocol::subscription::SubscriptionWorkerState as S; - maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, Some(S::Running), S::Idle); - let changed = execution.take_inputs(); - assert_eq!(changed.len(), 1); - assert!(changed[0].1.contains("Changed backlog")); - } - - #[tokio::test] - async fn orchestrator_attention_subscription_snapshots_only_establish_baselines() { - use crate::runtime_subscription::BrokerSubscriptionEvent as B; - use protocol::subscription::{ - SubscriptionEventPayload, SubscriptionSnapshot, SubscriptionWorker, - SubscriptionWorkerState as S, - }; - let (_dir, api, execution) = attention_test_api().await; - let started = start_attention_test_orchestrator(&api).await; - let worker = RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - started.worker.unwrap().worker_id, - ); - let attention = orchestrator_attention_state(&api, &worker); - attention.lock().unwrap().accepted = None; - execution.take_inputs(); - let summary = |state: S| -> SubscriptionWorker { - serde_json::from_value(serde_json::json!({ - "worker_id": worker.worker_id, "state": state, - })) - .unwrap() - }; - let snapshot = |state| B::Snapshot { - snapshot: SubscriptionSnapshot::Workers { - workers: vec![summary(state)], - }, - }; - let event = |state| B::Event { - payload: SubscriptionEventPayload::WorkerUpserted { - worker: summary(state), - }, - }; - let mut states = HashMap::new(); - assert!(observe_orchestrator_turn_end( - &api, - &mut states, - snapshot(S::Idle) - )); - assert!(observe_orchestrator_turn_end( - &api, - &mut states, - event(S::Idle) - )); - observe_orchestrator_turn_end(&api, &mut states, event(S::Running)); - // Neither snapshot itself nor an Idle event after reconnect is turn-end evidence. - observe_orchestrator_turn_end( - &api, - &mut states, - B::Disconnected { - message: "fixture reconnect".into(), - }, - ); - observe_orchestrator_turn_end(&api, &mut states, snapshot(S::Idle)); - observe_orchestrator_turn_end(&api, &mut states, event(S::Idle)); - observe_orchestrator_turn_end(&api, &mut states, snapshot(S::Running)); - observe_orchestrator_turn_end(&api, &mut states, snapshot(S::Idle)); - assert!(attention.lock().unwrap().accepted.is_none()); - assert!(execution.take_inputs().is_empty()); - observe_orchestrator_turn_end(&api, &mut states, event(S::Running)); - observe_orchestrator_turn_end(&api, &mut states, event(S::Idle)); - assert_eq!(execution.take_inputs().len(), 1); - } - - // Real Controller and canonical Session storage with isolated local config. - // A closed loopback model endpoint prevents any production-provider access; - // notifications commit at the model boundary before that request can fail. - struct AttentionWorkerFactory { - root: PathBuf, - } - - impl AttentionWorkerFactory { - async fn controller( - &self, - worker_id: String, - restore: bool, - ) -> std::result::Result - { - let name = format!("attention-{worker_id}"); - let root = self.root.join("workers").join(&worker_id); - let store = session_store::CombinedStore::new( - session_store::WorkerSessionStore::new(root.join("session")) - .map_err(|e| e.to_string())?, - session_store::WorkerAggregateStore::new(&root, &name) - .map_err(|e| e.to_string())?, - ); - let manifest = manifest::WorkerManifest::from_toml(&format!( - r#" - [worker] - name = "{name}" - pwd = "./" - [model] - scheme = "anthropic" - model_id = "attention-test" - base_url = "http://127.0.0.1:1" - auth = {{ kind = "none" }} - [engine] - max_tokens = 10 - [scope] - allow = [] - "# - )) - .map_err(|e| e.to_string())?; - let context = worker::WorkerWorkspaceContext::local_filesystem(None); - let workspace_client = context.client_handle(); - let loader = worker::PromptCatalogSource::builtins_only(); - let worker = if restore { - worker::Worker::restore_from_worker_metadata_with_context( - &name, - manifest, - store, - loader, - context, - worker::WorkerFilesystemAuthority::None, - ) - .await - } else { - worker::Worker::from_manifest_with_context( - manifest, - store, - loader, - context, - worker::WorkerFilesystemAuthority::None, - ) - .await - } - .map_err(|e| e.to_string())?; - let started = worker::PreparedWorker::new( - worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)), - worker::WorkerBootstrapLayout::RuntimeManagedRun { - run_dir: root.join(uuid::Uuid::now_v7().to_string()), - bash_output_dir: root.join("bash-output"), - }, - worker::WorkerControllerTransport::InProcess, - ) - .start() - .await - .map_err(|e| e.to_string())?; - Ok(worker_runtime::worker_backend::RuntimeWorkerController { - handle: started.handle, - shutdown: Arc::new(tokio::sync::Mutex::new(Some(started.shutdown))), - controller_task: started.controller_task, - workspace_client, - }) - } - } - - #[async_trait::async_trait] - impl worker_runtime::worker_backend::RuntimeWorkerFactory for AttentionWorkerFactory { - async fn spawn_controller( - &self, - request: worker_runtime::execution::WorkerExecutionSpawnRequest, - ) -> std::result::Result - { - self.controller(request.worker_ref.worker_id.to_string(), false) - .await - } - async fn restore_controller( - &self, - request: worker_runtime::execution::WorkerExecutionRestoreRequest, - ) -> std::result::Result - { - self.controller(request.worker_ref.worker_id.to_string(), true) - .await - } - } - - use worker_runtime::execution as attention_execution; - - // Fault injection occurs AFTER the real Controller durably accepted input. - struct AttentionResponseLossBackend { - inner: Arc, - lose_next: std::sync::atomic::AtomicBool, - attempts: Mutex>, - } - - impl attention_execution::WorkerExecutionBackend for AttentionResponseLossBackend { - fn backend_id(&self) -> &str { - self.inner.backend_id() - } - fn spawn_worker( - &self, - r: attention_execution::WorkerExecutionSpawnRequest, - ) -> attention_execution::WorkerExecutionSpawnResult { - self.inner.spawn_worker(r) - } - fn preflight_restore( - &self, - r: &attention_execution::WorkerExecutionRestoreRequest, - ) -> std::result::Result<(), attention_execution::WorkerExecutionResult> { - self.inner.preflight_restore(r) - } - fn reconcile_restore( - &self, - r: attention_execution::WorkerExecutionRestoreRequest, - ) -> attention_execution::WorkerExecutionSpawnResult { - self.inner.reconcile_restore(r) - } - fn restore_worker( - &self, - r: attention_execution::WorkerExecutionRestoreRequest, - ) -> attention_execution::WorkerExecutionSpawnResult { - self.inner.restore_worker(r) - } - fn activate_restored_worker( - &self, - id: attention_execution::WorkerLifecycleOperationId, - worker_ref: &worker_runtime::identity::WorkerRef, - ) -> std::result::Result<(), String> { - self.inner.activate_restored_worker(id, worker_ref) - } - fn dispatch_input( - &self, - worker_ref: &worker_runtime::identity::WorkerRef, - input: worker_runtime::interaction::WorkerInput, - ) -> attention_execution::WorkerExecutionResult { - self.attempts - .lock() - .unwrap() - .push(input.submission_request_id.clone().unwrap()); - let result = self.inner.dispatch_input(worker_ref, input); - assert_eq!( - result.outcome, - attention_execution::WorkerExecutionOutcome::Accepted - ); - if self - .lose_next - .swap(false, std::sync::atomic::Ordering::SeqCst) - { - attention_execution::WorkerExecutionResult::errored( - attention_execution::WorkerExecutionOperation::Input, - "injected loss of accepted response", - ) - } else { - result - } - } - fn dispatch_method( - &self, - worker_ref: &worker_runtime::identity::WorkerRef, - method: protocol::Method, - ) -> attention_execution::WorkerExecutionResult { - self.inner.dispatch_method(worker_ref, method) - } - fn stop_worker_operation( - &self, - r: attention_execution::WorkerExecutionStopRequest, - ) -> attention_execution::WorkerExecutionResult { - self.inner.stop_worker_operation(r) - } - fn worker_snapshot( - &self, - worker_ref: &worker_runtime::identity::WorkerRef, - ) -> Option { - self.inner.worker_snapshot(worker_ref) - } - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn orchestrator_attention_real_worker_start_restore_accept_and_commit_once_each() { - real_attention_worker_start_restore(false).await; - } - - #[tokio::test(flavor = "multi_thread", worker_threads = 2)] - async fn orchestrator_attention_real_worker_lost_response_retry_commits_once() { - real_attention_worker_start_restore(true).await; - } - - async fn real_attention_worker_start_restore(lose_response: bool) { - use session_store::{LogEntry, Store, WorkerSessionStore}; - let dir = tempfile::tempdir().unwrap(); - init_clean_git_workspace(dir.path()); - let config = test_server_config(dir.path()); - let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); - seed_test_registered_workspace(store.as_ref(), &config) - .await - .unwrap(); - // Real Worker Runtime adapter/controller/storage, isolated from profile - // workspace callbacks and real LLM credentials. - let execution = Arc::new( - worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new( - AttentionWorkerFactory { - root: config.embedded_runtime_store_root.clone(), - }, - ) - .unwrap(), - ); - let execution = Arc::new(AttentionResponseLossBackend { - inner: execution, - lose_next: std::sync::atomic::AtomicBool::new(lose_response), - attempts: Mutex::new(Vec::new()), - }); - let api = WorkspaceApi::new_with_execution_backend(config, store, execution.clone()) - .await - .unwrap(); - let mut input = ticket::NewTicket::new("Real notification backlog"); - input.workflow_state = Some(TicketWorkflowState::Queued); - set_test_ticket_target(&mut input, "test-repository", "HEAD"); - let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); - assign_test_orchestrator(&api, &ticket.id); - let started = start_attention_test_orchestrator(&api).await; - let worker = RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - started.worker.unwrap().worker_id, - ); - let session = WorkerSessionStore::open_read_only( - api.config - .embedded_runtime_store_root - .join("workers") - .join(&worker.worker_id) - .join("session"), - ) - .unwrap(); - let entries = || { - let id = session.session_id().unwrap().unwrap(); - session - .list_segments(id) - .unwrap() - .into_iter() - .flat_map(|segment| session.read_all_read_only(id, segment).unwrap()) - .collect::>() - }; - let receipts = || { - entries() - .iter() - .rev() - .find_map(|entry| match entry { - LogEntry::Extension { - domain, payload, .. - } if domain == "worker.pending_activations.v1" => { - Some(payload["notification_receipts"].as_array().unwrap().clone()) - } - LogEntry::AnnotatedSystemItem { extensions, .. } => extensions - .iter() - .find(|e| e.domain == "worker.pending_activations.v1") - .map(|e| { - e.payload["notification_receipts"] - .as_array() - .unwrap() - .clone() - }), - _ => None, - }) - .unwrap() - }; - let commits = || { - entries().iter().filter(|entry| matches!(entry, - LogEntry::AnnotatedSystemItem { entry, .. } - if matches!(&entry.item, session_store::SystemItem::Notification { message, .. } - if message.starts_with("Queued Tickets require attention:")) - )).count() - }; - async fn wait_for_commits usize>(count: F, expected: usize) { - tokio::time::timeout(std::time::Duration::from_secs(5), async { - loop { - let actual = count(); - assert!(actual <= expected, "duplicate canonical notifications"); - if actual == expected { - break; - } - tokio::time::sleep(std::time::Duration::from_millis(10)).await; - } - }) - .await - .expect("controlled Worker notification commit"); - } - assert_eq!(receipts().len(), 1); - wait_for_commits(&commits, 1).await; - assert_eq!( - execution.attempts.lock().unwrap().len(), - if lose_response { 2 } else { 1 } - ); - let first_id = orchestrator_attention_state(&api, &worker) - .lock() - .unwrap() - .accepted - .as_ref() - .unwrap() - .request_id - .clone(); - use protocol::subscription::SubscriptionWorkerState as S; - for previous in [ - None, - Some(S::Idle), - Some(S::Stopped), - Some(S::Paused), - Some(S::Running), - ] { - maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, previous, S::Idle); - } - assert_eq!( - start_attention_test_orchestrator(&api).await.disposition, - "existing" - ); - assert_eq!(receipts().len(), 1); - api.runtime - .stop_worker( - &worker, - WorkerLifecycleRequest { - reason: None, - ticket_assignment: None, - }, - ) - .unwrap(); - let restored = start_attention_test_orchestrator(&api).await; - assert_eq!(restored.disposition, "restored"); - let second_id = orchestrator_attention_state(&api, &worker) - .lock() - .unwrap() - .accepted - .as_ref() - .unwrap() - .request_id - .clone(); - assert_ne!(first_id, second_id); - wait_for_commits(&commits, 2).await; - let attempts = execution.attempts.lock().unwrap(); - assert_eq!(attempts.len(), if lose_response { 3 } else { 2 }); - assert_eq!( - attempts.iter().filter(|id| **id == first_id).count(), - if lose_response { 2 } else { 1 } - ); - assert_eq!(attempts.iter().filter(|id| **id == second_id).count(), 1); - drop(attempts); - let receipts = receipts(); - assert_eq!(receipts.len(), 2); - assert_eq!( - receipts - .iter() - .filter(|r| r["notification_request_id"] == first_id) - .count(), - 1 - ); - assert_eq!( - receipts - .iter() - .filter(|r| r["notification_request_id"] == second_id) - .count(), - 1 - ); - // Stop only this isolated test fixture; no live dogfooding Worker is touched. - api.runtime - .stop_worker( - &worker, - WorkerLifecycleRequest { - reason: None, - ticket_assignment: None, - }, - ) - .unwrap(); - } - - #[tokio::test] - async fn orchestrator_attention_restore_and_replacement_have_first_delivery() { - let (_dir, api, execution) = attention_test_api().await; - let started = start_attention_test_orchestrator(&api).await; - let worker = RuntimeWorkerRef::new( - EMBEDDED_WORKER_RUNTIME_ID, - started.worker.unwrap().worker_id, - ); - assert_eq!(execution.take_inputs().len(), 1); - api.runtime - .stop_worker( - &worker, - WorkerLifecycleRequest { - reason: None, - ticket_assignment: None, - }, - ) - .unwrap(); - execution.accept_restores(); - let restored = start_attention_test_orchestrator(&api).await; - assert_eq!(restored.disposition, "restored"); - assert_eq!(restored.worker.unwrap().worker_id, worker.worker_id); - assert_eq!(execution.take_inputs().len(), 1); - use protocol::subscription::SubscriptionWorkerState as S; - maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, None, S::Idle); - assert_eq!( - start_attention_test_orchestrator(&api).await.disposition, - "existing" - ); - assert!(execution.take_inputs().is_empty()); - // This is a fixture Worker, not the running dogfooding Orchestrator. - api.runtime.delete_worker(&worker).unwrap(); - api.worker_projection - .publish_ordered(|| { - api.store - .delete_worker_registry(TEST_WORKSPACE_ID, &worker) - .map(|commit| ((), commit)) - }) - .unwrap(); - let replacement = start_attention_test_orchestrator(&api).await; - assert_ne!(replacement.worker.unwrap().worker_id, worker.worker_id); - assert_eq!(execution.take_inputs().len(), 1); - } - - #[tokio::test] - async fn orchestrator_running_to_idle_recovers_queued_ticket_without_notification_memory() { - let dir = tempfile::tempdir().unwrap(); - init_clean_git_workspace(dir.path()); - let (api, execution) = test_api_with_recording_backend(dir.path()).await; - let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Recover queued work"); - input.workflow_state = Some(TicketWorkflowState::Queued); - set_test_ticket_target(&mut input, "test-repository", "HEAD"); - let ticket_ref = backend.create(input).unwrap(); - assign_test_orchestrator(&api, &ticket_ref.id); - - let Json(started) = scoped_start_workspace_orchestrator( - State(api.clone()), - AxumPath(ScopedWorkspacePath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - }), - ) - .await - .unwrap(); - assert!(started.online); - let startup_inputs = execution.take_inputs(); - assert_eq!(startup_inputs.len(), 1); - let worker_id = started.worker.as_ref().unwrap().worker_id.clone(); - let worker = RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, &worker_id); - let attention = orchestrator_attention_state(&api, &worker); - assert_eq!( - attention - .lock() - .unwrap() - .accepted - .as_ref() - .unwrap() - .fingerprint, - ticket_ref.id - ); - attention.lock().unwrap().accepted = None; - use protocol::subscription::SubscriptionWorkerState as S; - // Snapshot/reconnection and non-turn transitions cannot stand in for startup. - for previous in [None, Some(S::Idle), Some(S::Stopped), Some(S::Paused)] { - maybe_dispatch_orchestrator_turn_end(&api, &worker_id, previous, S::Idle); - assert!(attention.lock().unwrap().accepted.is_none()); - assert!(execution.take_inputs().is_empty()); - } - maybe_dispatch_orchestrator_turn_end(&api, &worker_id, Some(S::Running), S::Idle); - assert_eq!( - attention - .lock() - .unwrap() - .accepted - .as_ref() - .unwrap() - .fingerprint, - ticket_ref.id - ); - maybe_dispatch_orchestrator_turn_end(&api, &worker_id, Some(S::Running), S::Idle); - let notifications = execution.take_inputs(); - assert_eq!(notifications.len(), 1); - assert_eq!(notifications[0].0.worker_id.to_string(), worker_id); - let content = ¬ifications[0].1; - assert!(content.starts_with("Queued Tickets require attention:")); - assert!( - content.contains(&format!( - "- {} — Recover queued work", - ticket_ref.resource_key.as_deref().unwrap() - )), - "unexpected notification body: {content:?}" - ); - assert!(content.contains("Reread the current Ticket state before acting")); - assert!(!content.contains(ticket_ref.id.as_str())); - assert!(!content.contains(TEST_WORKSPACE_ID)); - assert!(!content.contains("bounded")); - assert!(!content.contains("omitted")); - - let candidates = backend - .list(ticket::TicketListQuery::states([ - ticket::TicketListState::Queued, - ])) - .unwrap(); - let mut truncated_candidates = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS - + 1) - .map(|index| { - let mut candidate = candidates[0].clone(); - candidate.id = format!("opaque-{index}"); - candidate.resource_key = Some(format!("T-{index}")); - candidate.title = format!("Queued {index}"); - candidate - }) - .collect::>(); - let truncated = orchestrator_queue_attention_context( - TEST_WORKSPACE_ID, - TEST_WORKSPACE_ID, - &truncated_candidates, - ) - .unwrap(); - let rendered = worker::PromptCatalog::builtins_only() - .unwrap() - .orchestrator_queue_attention( - worker::OrchestratorQueueAttentionPrompt::Server, - &truncated, - ) - .unwrap(); - assert!(rendered.contains("- T-20 — Queued 20")); - assert!(!rendered.contains("T-21")); - assert!(rendered.contains("were omitted from this notice: 1")); - assert!(!rendered.contains("opaque-")); - - truncated_candidates[0].resource_key = None; - assert_eq!( - orchestrator_queue_attention_context( - TEST_WORKSPACE_ID, - TEST_WORKSPACE_ID, - &truncated_candidates - ) - .unwrap_err(), - "missing_ticket_resource_key" - ); - assert_eq!( - orchestrator_queue_attention_context( - TEST_WORKSPACE_ID, - "foreign-workspace", - &truncated_candidates - ) - .unwrap_err(), - "foreign_workspace_ticket_projection" - ); - } + include!("server_assignment_attention_tests.rs"); #[tokio::test] async fn runtime_worker_create_rejects_reserved_singleton_key_squatting_and_authority_forgery() @@ -51031,7 +53326,7 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Investigation without a checkout"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut input, "test-repository", "develop"); let ticket = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket.id); @@ -51047,6 +53342,7 @@ mod tests { }, profile: server_api::RuntimeProfileSelector::Builtin("builtin:intake".to_string()), ticket_assignment: Some(server_api::RuntimeWorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: ticket.id.clone(), operation_id: "runtime-explicit-work".to_string(), }), @@ -51087,7 +53383,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::InProgress + TicketState::Open ); let Json(replay) = scoped_create_runtime_worker( State(api), @@ -51137,6 +53433,7 @@ mod tests { ticket_id ); let mut assignment = Some(CreateWorkspaceWorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: ticket_resource_key.clone(), operation_id: "ticket-key-assignment".to_string(), }); @@ -51151,7 +53448,7 @@ mod tests { id: ticket_id.clone(), }; let mut related_input = ticket::NewTicket::new("Related Browser Ticket"); - related_input.workflow_state = Some(TicketWorkflowState::Ready); + related_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut related_input, "test-repository", "develop"); let related_ticket_id = browser_ticket_backend(&api) .unwrap() @@ -51235,7 +53532,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(ready.state, "ready"); + assert_eq!(ready.state.as_str(), "open"); assign_test_orchestrator(&api, &ticket_id); assign_test_orchestrator(&api, &related_ticket_id); let Json(ready_detail) = scoped_get_ticket(State(api.clone()), AxumPath(path())) @@ -51265,26 +53562,33 @@ mod tests { let Json(queued) = scoped_get_ticket(State(api.clone()), AxumPath(path())) .await .unwrap(); - assert_eq!(queued.state, "queued"); + assert_eq!(queued.state.as_str(), "open"); assert_eq!(queued.queued_by.as_deref(), Some("workspace-web")); assert_eq!(queued.relations.blockers.len(), 1); assert_eq!(queued.relations.blockers[0].reason_kind, "depends_on"); - let Json(closed) = scoped_close_ticket( + let before = browser_ticket_backend(&api) + .unwrap() + .show(ticket_id.clone().into()) + .unwrap(); + let mut request = case_test_request( + &before, + "browser-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + ); + request.explanation = "Closed through the Browser API.".into(); + let Json(closed) = scoped_ticket_case_update( State(api), HeaderMap::new(), - AxumPath(path()), - Json(server_api::BrowserCloseTicketRequest { - operation_key: "browser-close".into(), - expected_content_digest: queued.content_digest.clone(), - expected_state: server_api::BrowserTicketWorkflowState::Queued, - resolution: "Closed through the Browser API.".to_string(), - }), + AxumPath((TEST_WORKSPACE_ID.into(), ticket_id.clone())), + Json(request), ) .await .unwrap(); - assert_eq!(closed.state, "closed"); + assert_eq!(closed.meta.workflow_state, TicketState::Closed); assert_eq!( - closed.resolution.as_deref(), + closed.resolution.as_ref().map(|text| text.as_str()), Some("Closed through the Browser API.") ); } @@ -51680,11 +53984,41 @@ mod tests { assert_eq!(stale.into_response().status(), StatusCode::CONFLICT); } + pub(super) fn seed_test_delivery_binding(api: &WorkspaceApi) { + if api + .config_store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, EMBEDDED_WORKER_RUNTIME_ID) + .unwrap() + .is_some() + { + return; + } + let identity = (*EMBEDDED_RUNTIME_REQUEST_IDENTITY).clone(); + api.config_store + .upsert_workspace_runtime_binding( + WorkspaceRuntimeBinding { + workspace_id: TEST_WORKSPACE_ID.into(), + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(), + display_name: "Embedded test Runtime".into(), + base_url: "in-process://embedded".into(), + public_key: identity.public_key, + public_key_fingerprint: String::new(), + binding_id: "binding-test".into(), + authentication_mode: StoredRuntimeAuthenticationMode::LegacyServerIssuer, + created_at: TEST_CREATED_AT.into(), + updated_at: TEST_CREATED_AT.into(), + revoked_at: None, + }, + false, + ) + .unwrap(); + } + async fn test_api_with_recording_backend( workspace_root: impl Into, ) -> (WorkspaceApi, Arc) { let config = test_server_config(workspace_root); - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); let execution = Arc::new(DeterministicExecutionBackend::default()); let api = WorkspaceApi::new_with_execution_backend(config, Arc::new(store), execution.clone()) @@ -51708,7 +54042,7 @@ mod tests { config.repositories[0].source = source; config.repositories[0].path = None; config.repositories[0].default_selector = Some("develop".to_string()); - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); WorkspaceApi::new_with_execution_backend( config, Arc::new(store), @@ -51737,7 +54071,7 @@ mod tests { docs.path = Some(docs_root); docs.default_selector = Some("develop".to_string()); config.repositories.push(docs); - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); WorkspaceApi::new_with_execution_backend( config, Arc::new(store), @@ -52172,6 +54506,7 @@ mod tests { store, Arc::new(DeterministicExecutionBackend::default()), BackendResourceBroker::default(), + HostExecutors::default(), Some(dispatcher.clone()), ) .await @@ -52417,7 +54752,7 @@ mod tests { cached_status: "connected".to_string(), timeout: std::time::Duration::from_secs(1), }); - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); let owner_account_id = format!("account-{TEST_WORKSPACE_ID}"); store .upsert_account(&crate::store::AccountRecord { @@ -52717,7 +55052,8 @@ mod tests { let mut revoked = trust; revoked.revoked_at = Some("2026-08-11T00:01:00Z".to_string()); - let authority = SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap(); + let authority = + SqliteWorkspaceStore::open_for_test(api.config.database_path.clone()).unwrap(); authority .upsert_workspace_runtime_binding(revoked, true) .unwrap(); @@ -52823,7 +55159,7 @@ mod tests { "INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit - ) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', '', 'inprogress', 1)", + ) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', '', 'open', 1)", rusqlite::params![api.config.workspace_id, ticket_id], ) .unwrap(); @@ -53051,6 +55387,7 @@ mod tests { worker_id: "legacy-worker".to_string(), }), Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: "runtime-source".to_string(), worker_id: Some("runtime-worker".to_string()), }), @@ -53063,6 +55400,7 @@ mod tests { contract_workdir_removal_source_actor(&context( None, Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: "runtime-source".to_string(), worker_id: Some("runtime-worker".to_string()), }), @@ -53075,6 +55413,7 @@ mod tests { contract_workdir_removal_source_actor(&context( None, Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: "runtime-only".to_string(), worker_id: None, }), @@ -53086,6 +55425,7 @@ mod tests { let error = contract_workdir_removal_source_actor(&context( None, Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: "runtime-only".to_string(), worker_id: None, }), @@ -53643,7 +55983,7 @@ mod tests { .unwrap() .digest ); - let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let restarted = SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap(); assert_eq!( changed.digest, restarted @@ -54189,7 +56529,7 @@ mod tests { .unwrap(); assert_eq!(capability_changed.items, reattached.items); assert_ne!(capability_changed.digest, reattached.digest); - let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let restarted = SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap(); let (restarted_page, restarted_digest) = restarted .list_worker_workdir_links_page_with_digest( TEST_WORKSPACE_ID, @@ -54208,7 +56548,7 @@ mod tests { .unwrap() .run_to_completion(128, std::time::Duration::from_millis(1), None) .unwrap(); - let restored = SqliteWorkspaceStore::from_connection(snapshot).unwrap(); + let restored = SqliteWorkspaceStore::from_connection_for_test(snapshot).unwrap(); assert_eq!( restored .list_worker_workdir_links_page_with_digest( @@ -54253,7 +56593,7 @@ mod tests { ) .unwrap(); assert_ne!(read_write_digest, capability_changed.digest); - let reader = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let reader = SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap(); let barrier = Arc::new(std::sync::Barrier::new(2)); std::thread::scope(|scope| { let worker = &fixture.worker; @@ -55417,6 +57757,8 @@ mod tests { .unwrap(); let worker = orchestrator.worker.unwrap(); let worker = RuntimeWorkerRef::new(worker.runtime_id, worker.worker_id); + let mut stop_events = api.worker_projection.subscribe_ordered(10).unwrap(); + let _ = stop_events.take_snapshot(); api.runtime .stop_worker( &worker, @@ -55426,6 +57768,25 @@ mod tests { }, ) .unwrap(); + // A synchronous Runtime stop no longer implies that its queued SQL + // projection was published. Observe stopped before taking the removal + // baseline, rather than mistaking its Upsert for the atomic removal. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let event = stop_events.recv().await.unwrap(); + if event.changes.iter().any(|change| matches!( + change, + crate::store::WorkerCatalogChange::Upsert(record) + if record.registry.worker == worker + && record.observation.as_ref().is_some_and(|observation| + observation.worker.state == protocol::subscription::SubscriptionWorkerState::Stopped) + )) { + break; + } + } + }) + .await + .expect("stopped Runtime observation must commit before removal baseline"); let worker_root = workspace .path() .join(".test-embedded-runtime-store/workers") @@ -55752,7 +58113,7 @@ mod tests { let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); crate::store::configure_sqlite(&conn).unwrap(); - for (state, status) in [("done", "open"), ("closed", "closed")] { + for (state, status) in [("closed", "closed"), ("open", "open")] { conn.execute( "UPDATE typed_tickets SET workflow_state = ?1, status = ?2 WHERE workspace_id = ?3 AND ticket_id = ?4", @@ -55801,7 +58162,7 @@ mod tests { ); let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); crate::store::configure_sqlite(&conn).unwrap(); - for (state, status) in [("done", "open"), ("closed", "closed")] { + for (state, status) in [("closed", "closed"), ("open", "open")] { conn.execute( "UPDATE typed_tickets SET workflow_state = ?1, status = ?2 WHERE workspace_id = ?3 AND ticket_id = ?4", @@ -56087,7 +58448,7 @@ mod tests { ) .unwrap(); let result = crate::worker_source::verify_runtime_request_source_proof_with_store( - api.store.as_ref(), + api.store.clone(), &api.config, &proof, other_workspace, @@ -56113,7 +58474,7 @@ mod tests { let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate("runtime-test").unwrap(); configure_runtime_request_auth(&mut api, &identity, "runtime-test"); - let store = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap(); let memory_settings = store .get_workspace_memory_settings(TEST_WORKSPACE_ID) .unwrap(); @@ -56148,7 +58509,7 @@ mod tests { let proof = issue(); let source = crate::worker_source::verify_runtime_request_source_proof_with_store( - api.store.as_ref(), + api.store.clone(), &api.config, &proof, TEST_WORKSPACE_ID, @@ -56166,7 +58527,7 @@ mod tests { .unwrap(); let proof = issue(); let result = crate::worker_source::verify_runtime_request_source_proof_with_store( - api.store.as_ref(), + api.store.clone(), &api.config, &proof, TEST_WORKSPACE_ID, @@ -58313,9 +60674,9 @@ mod tests { std::fs::write(static_dir.join("index.html"), "
Yoi Workspace
").unwrap(); std::fs::write(static_dir.join("assets/app.js"), "console.log('yoi');").unwrap(); - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); let mut config = test_server_config(dir.path()); - let sqlite_store = SqliteWorkspaceStore::open(&config.database_path).unwrap(); + let sqlite_store = SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap(); sqlite_store .upsert_workspace(&WorkspaceRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), @@ -58348,9 +60709,9 @@ mod tests { &config.database_path, TEST_WORKSPACE_ID, "API Ticket", - ticket::TicketWorkflowState::Ready, + ticket::TicketState::Open, ); - let sqlite_store = SqliteWorkspaceStore::open(&config.database_path).unwrap(); + let sqlite_store = SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap(); sqlite_store .upsert_workspace(&WorkspaceRecord { workspace_id: TEST_WORKSPACE_ID.to_string(), @@ -58514,7 +60875,7 @@ mod tests { let tickets = get_json(app.clone(), "/api/tickets").await; assert_eq!(tickets["items"][0]["title"], "API Ticket"); - assert_eq!(tickets["items"][0]["state"], "ready"); + assert_eq!(tickets["items"][0]["state"], "open"); let objectives = get_json(app.clone(), "/api/objectives").await; assert_eq!(objectives["items"].as_array().unwrap().len(), 1); @@ -59706,10 +62067,13 @@ mod tests { for delayed in delayed_protocol_test_methods() { let (runtime, worker_ref, execution) = runtime_with_worker_backend(); let source = embedded_protocol_test_source(&runtime, &worker_ref); - let mut old = - connect_embedded_worker_protocol(source.clone(), embedded_protocol_test_scope()) - .await - .unwrap(); + let mut old = connect_embedded_worker_protocol( + source.clone(), + embedded_protocol_test_scope(), + HostExecutors::default().host().unwrap(), + ) + .await + .unwrap(); assert_embedded_protocol_snapshot( next_embedded_protocol_event(&mut old).await, &worker_ref, @@ -59773,10 +62137,13 @@ mod tests { &[worker_ref.clone()] ); - let mut current = - connect_embedded_worker_protocol(source, embedded_protocol_test_scope()) - .await - .unwrap(); + let mut current = connect_embedded_worker_protocol( + source, + embedded_protocol_test_scope(), + HostExecutors::default().host().unwrap(), + ) + .await + .unwrap(); assert_embedded_protocol_snapshot( next_embedded_protocol_event(&mut current).await, &worker_ref, @@ -59924,10 +62291,13 @@ mod tests { for lagged in [false, true] { let (runtime, worker_ref, execution) = runtime_with_worker_backend(); let source = embedded_protocol_test_source(&runtime, &worker_ref); - let mut bridge = - connect_embedded_worker_protocol(source.clone(), embedded_protocol_test_scope()) - .await - .unwrap(); + let mut bridge = connect_embedded_worker_protocol( + source.clone(), + embedded_protocol_test_scope(), + HostExecutors::default().host().unwrap(), + ) + .await + .unwrap(); assert_embedded_protocol_snapshot( next_embedded_protocol_event(&mut bridge).await, &worker_ref, @@ -59969,6 +62339,7 @@ mod tests { let mut bridge = connect_embedded_worker_protocol( embedded_protocol_test_source(&runtime, &worker_ref), embedded_protocol_test_scope(), + HostExecutors::default().host().unwrap(), ) .await .unwrap(); @@ -60029,9 +62400,13 @@ mod tests { let wrong_scope = worker_runtime::RuntimeWorkspaceScope::new("other-workspace", "local-token"); assert!( - connect_embedded_worker_protocol(source.clone(), wrong_scope.clone()) - .await - .is_err() + connect_embedded_worker_protocol( + source.clone(), + wrong_scope.clone(), + HostExecutors::default().host().unwrap() + ) + .await + .is_err() ); let (mut socket, server) = spawn_embedded_protocol_test_ws(source.clone(), wrong_scope, false).await; @@ -60874,7 +63249,7 @@ mod tests { async fn objective_mutation_endpoints_round_trip_through_workspace_authority() { let dir = tempfile::tempdir().unwrap(); let config = test_server_config(dir.path()); - let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); let owner_account_id = format!("account-{TEST_WORKSPACE_ID}"); store .upsert_account(&crate::store::AccountRecord { @@ -60905,8 +63280,8 @@ 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); + ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1), + ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1); INSERT INTO workspace_resource_keys ( workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at ) VALUES @@ -61290,6 +63665,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); actor: None, worker_source: None, runtime_source: Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: dedicated.worker.runtime_id.clone(), worker_id: Some(dedicated.worker.worker_id.clone()), }), @@ -61313,6 +63689,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); actor: None, worker_source: None, runtime_source: Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), runtime_id: forged.worker.runtime_id.clone(), worker_id: Some(forged.worker.worker_id.clone()), }), @@ -61400,7 +63777,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); database_path: &Path, workspace_id: &str, title: &str, - state: ticket::TicketWorkflowState, + state: ticket::TicketState, ) -> String { use ticket::TicketBackend as _; diff --git a/crates/workspace-server/src/server/subjektiv_job.rs b/crates/workspace-server/src/server/subjektiv_job.rs index 0fcb072af..33ef01ed4 100644 --- a/crates/workspace-server/src/server/subjektiv_job.rs +++ b/crates/workspace-server/src/server/subjektiv_job.rs @@ -65,70 +65,85 @@ pub(super) fn require_candidate_grant( /// candidate decision. Once Idle is confirmed, stop cancels any legacy surface /// background work; the new Job regenerates it and does not infer surface success. pub(super) async fn retire_legacy_worker(api: &WorkspaceApi, subject_id: &str) -> ApiResult { + let job_api = api.clone(); + let subject_id = subject_id.to_owned(); let key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{subject_id}"); - let Some(owner) = api - .store - .current_worker_singleton_owner(api.workspace_id(), &key)? - else { - return Ok(true); + let (worker, complete) = api + .host_executors + .host()? + .run(move || { + let api = &job_api; + let key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{subject_id}"); + let Some(owner) = api + .store + .current_worker_singleton_owner(api.workspace_id(), &key)? + else { + return Ok((None, true)); + }; + let mut summary = api + .runtime + .worker(&owner.worker) + .map_err(|e| e.into_error())?; + summary.singleton_key = Some(owner.key.clone()); + require_dedicated_subjektiv_consolidation_worker(&summary, api.workspace_id(), &key)?; + let stopped = summary.state.eq_ignore_ascii_case("stopped"); + let idle = summary.state.eq_ignore_ascii_case("idle") + && summary + .worker_state + .as_ref() + .is_some_and(|s| s.state == protocol::WorkerState::Idle); + if !stopped && !idle { + return Ok((None, false)); + } + if api + .store + .current_worker_singleton_owner(api.workspace_id(), &key)? + .as_ref() + .map(|o| &o.worker) + != Some(&owner.worker) + { + return Err(Error::RepositoryConflict( + "legacy consolidation owner moved during cutover".into(), + ) + .into()); + } + if !summary.state.eq_ignore_ascii_case("stopped") { + let stopped = api + .runtime + .stop_worker( + &owner.worker, + WorkerLifecycleRequest { + reason: Some( + "Retire legacy subjektiv consolidator after foreground completion" + .into(), + ), + ticket_assignment: None, + }, + ) + .map_err(|e| e.into_error())?; + if stopped.state != InternalWorkerOperationState::Accepted { + return Ok((None, false)); + } + } + if !api + .runtime + .worker(&owner.worker) + .map_err(|e| e.into_error())? + .state + .eq_ignore_ascii_case("stopped") + { + return Ok((None, false)); + } + Ok::<_, ApiError>((Some(owner.worker), false)) + }) + .await?; + let Some(worker) = worker else { + return Ok(complete); }; - let mut summary = api - .runtime - .worker(&owner.worker) - .map_err(|e| e.into_error())?; - summary.singleton_key = Some(owner.key.clone()); - require_dedicated_subjektiv_consolidation_worker(&summary, api.workspace_id(), &key)?; - let stopped = summary.state.eq_ignore_ascii_case("stopped"); - let idle = summary.state.eq_ignore_ascii_case("idle") - && summary - .worker_state - .as_ref() - .is_some_and(|s| s.state == protocol::WorkerState::Idle); - if !stopped && !idle { - return Ok(false); - } - if api - .store - .current_worker_singleton_owner(api.workspace_id(), &key)? - .as_ref() - .map(|o| &o.worker) - != Some(&owner.worker) - { - return Err(Error::RepositoryConflict( - "legacy consolidation owner moved during cutover".into(), - ) - .into()); - } - if !summary.state.eq_ignore_ascii_case("stopped") { - let stopped = api - .runtime - .stop_worker( - &owner.worker, - WorkerLifecycleRequest { - reason: Some( - "Retire legacy subjektiv consolidator after foreground completion".into(), - ), - ticket_assignment: None, - }, - ) - .map_err(|e| e.into_error())?; - if stopped.state != InternalWorkerOperationState::Accepted { - return Ok(false); - } - } - if !api - .runtime - .worker(&owner.worker) - .map_err(|e| e.into_error())? - .state - .eq_ignore_ascii_case("stopped") - { - return Ok(false); - } let removal = WorkerRemovalService::new(api) .execute_target_removal( api.runtime.as_ref(), - &owner.worker, + &worker, "subjektiv consolidation Job cutover", None, ) @@ -141,10 +156,17 @@ pub(super) async fn retire_legacy_worker(api: &WorkspaceApi, subject_id: &str) - )) .into()); } - Ok(api - .store - .current_worker_singleton_owner(api.workspace_id(), &key)? - .is_none()) + let store = api.store.clone(); + let workspace_id = api.workspace_id().to_owned(); + api.store + .db_executor() + .run(move || { + store + .current_worker_singleton_owner(&workspace_id, &key) + .map(|owner| owner.is_none()) + .map_err(ApiError::from) + }) + .await } fn bounded_candidate_batch( diff --git a/crates/workspace-server/src/server/tests/api_logging_tests.rs b/crates/workspace-server/src/server/tests/api_logging_tests.rs index 803f8392a..2bac1950b 100644 --- a/crates/workspace-server/src/server/tests/api_logging_tests.rs +++ b/crates/workspace-server/src/server/tests/api_logging_tests.rs @@ -131,8 +131,7 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() { }) .unwrap(); let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Cancellation logging"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + let input = ticket::NewTicket::new("Cancellation logging"); let ticket = backend.create(input).unwrap(); api.store .set_current_ticket_worker_assignment( @@ -197,8 +196,8 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() { "{diagnostics}" ); assert_eq!( - api.authority.ticket(&ticket.id).unwrap().state, - "inprogress" + api.authority.ticket(&ticket.id).unwrap().state.as_str(), + "open" ); // Errors without diagnostics still carry a useful kind and message. @@ -213,7 +212,32 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() { *execution.cancel_failure.lock().unwrap() = None; let (status, body, events) = request_with_logs(app, request("body-secret")).await; assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["state"], "ready"); + assert_eq!(body["state"], "open"); + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + assert!( + api.store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + let assignment_history = api + .store + .list_ticket_worker_assignment_events(TEST_WORKSPACE_ID, &ticket.id, 10) + .unwrap(); + assert!( + assignment_history + .iter() + .any(|event| event.action == "unassigned" + && event.previous_assignment_id.as_deref() == Some("logging-assignment")) + ); + let after = backend.show(ticket.id.into()).unwrap(); + assert!(after.case_history.is_empty()); + assert!(after.current_closure.is_none()); assert!( events.is_empty(), "successful cancellation logged as failure: {events:?}" @@ -782,7 +806,19 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc .unwrap(); }; let ((status, body, events), ()) = tokio::join!( - request_with_logs(build_router(fixture.api.clone()), request), + async { + let result = request_with_logs(build_router(fixture.api.clone()), request).await; + // A pre-dispatch rejection leaves the fake provider waiting on an + // open channel. Fail with the actual response here instead of + // hanging join! until the outer suite timeout hides that cause. + assert_eq!( + result.0, + StatusCode::BAD_GATEWAY, + "early command-start response: {}", + result.1 + ); + result + }, provider ); assert_eq!(status, StatusCode::BAD_GATEWAY, "{body}"); @@ -808,3 +844,78 @@ async fn self_workdir_post_start_registration_failure_does_not_claim_pre_dispatc "post-dispatch failure was retried" ); } + +#[tokio::test] +async fn saturated_host_before_command_start_returns_availability_without_a_provider_operation() { + let mut fixture = manual_worker_assignment_fixture().await; + let identity = + worker_runtime::auth::RuntimeIdentityMaterial::generate(&fixture.worker.runtime_id) + .unwrap(); + configure_runtime_request_auth(&mut fixture.api, &identity, &fixture.worker.runtime_id); + let (sender, mut commands) = tokio::sync::mpsc::channel(1); + let connection = Arc::new(ExternalProviderConnection { + grant_id: "overload-grant".into(), + workdir_id: fixture.main_workdir_id.clone(), + provider_instance_id: "overload-provider".into(), + generation: 1, + expires_at: None, + capabilities: workdir::WorkdirSessionCapabilities::ALL, + admission: Arc::new(tokio::sync::Semaphore::new(1)), + shutdown_confirmed: Arc::new(std::sync::atomic::AtomicBool::new(false)), + sender, + }); + fixture.runtime.workdir_session_factory = Some(Arc::new(move |_| { + Ok(Arc::new(ExternalProviderWorkdirSession::new( + connection.clone(), + None, + )) as WorkdirSessionHandle) + })); + fixture + .api + .runtime + .register_or_replace(fixture.runtime.clone()); + let host = fixture.api.host_executors.host().unwrap(); + host.drain().await.unwrap(); + let mut releases = Vec::new(); // dropping senders also releases jobs on panic + for _ in 0..2 { + let (release, permit) = std::sync::mpsc::channel::<()>(); + releases.push(release); + let (started, accepted) = tokio::sync::oneshot::channel(); + host.dispatch(move || { + let _ = started.send(()); + let _ = permit.recv_timeout(std::time::Duration::from_secs(10)); + }) + .unwrap(); + accepted.await.unwrap(); + } + for _ in 0..16 { + host.dispatch(|| {}).unwrap(); + } + let path = format!("/api/w/{TEST_WORKSPACE_ID}/workers/self/workdir-session/operations"); + let request = runtime_source_request( + &identity, + Some(&fixture.worker.worker_id), + "POST", + &path, + serde_json::to_vec(&json!({ + "target_workdir": "checkout", + "operation": {"operation": "command_start", "request": { + "command": "not-dispatched", "timeout_secs": 1, "output_limit": 1024, + "cwd": "", "spill_dir": null, "tool_call_id": "not-dispatched" + }} + })) + .unwrap(), + ); + let (status, body, _) = request_with_logs(build_router(fixture.api.clone()), request).await; + assert_eq!(status, StatusCode::SERVICE_UNAVAILABLE, "{body}"); + assert!(body.to_string().contains("db_capacity_exceeded"), "{body}"); + // The response is already final but the live fake-provider receiver remains + // Pending. An unbounded join of those two futures hid this causal rejection + // behind the original 600-second suite timeout. No process or command ran. + let provider_command = commands.recv(); + tokio::pin!(provider_command); + assert!(futures::poll!(provider_command.as_mut()).is_pending()); + drop(releases); + host.drain().await.unwrap(); + fixture.api.store.db_executor().drain().await.unwrap(); +} diff --git a/crates/workspace-server/src/server/tests/auth_logging_tests.rs b/crates/workspace-server/src/server/tests/auth_logging_tests.rs index b4b51d7bc..d5bf3334a 100644 --- a/crates/workspace-server/src/server/tests/auth_logging_tests.rs +++ b/crates/workspace-server/src/server/tests/auth_logging_tests.rs @@ -54,7 +54,7 @@ async fn rejected_runtime_proofs_log_reason_and_verification_without_secrets_on_ configure_runtime_request_auth(&mut api, &identity, "runtime-test"); let revoked = RuntimeIdentityMaterial::generate("revoked-runtime-secret").unwrap(); configure_runtime_request_auth(&mut api, &revoked, "revoked-runtime-secret"); - SqliteWorkspaceStore::open(&api.config.database_path) + SqliteWorkspaceStore::open_for_test(&api.config.database_path) .unwrap() .revoke_workspace_runtime_binding( TEST_WORKSPACE_ID, @@ -70,7 +70,7 @@ async fn rejected_runtime_proofs_log_reason_and_verification_without_secrets_on_ let body = "body-secret"; let apps = [ build_router(api.clone()), - workspace_server_router(WorkspaceServerApi::new( + workspace_server_router(WorkspaceServerApi::new_for_test( api.config.clone(), api.store.clone(), )), @@ -318,7 +318,7 @@ async fn runtime_proof_setup_failure_logs_no_internal_error_and_bounds_request_m &error, ) }); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + assert_eq!(response.status(), StatusCode::SERVICE_UNAVAILABLE); assert!( to_bytes(response.into_body(), usize::MAX) .await @@ -347,7 +347,7 @@ async fn repository_runtime_proof_401_keeps_generic_envelope_on_both_routers() { let path = format!("/api/w/{TEST_WORKSPACE_ID}/repositories"); for app in [ build_router(api.clone()), - workspace_server_router(WorkspaceServerApi::new( + workspace_server_router(WorkspaceServerApi::new_for_test( api.config.clone(), api.store.clone(), )), diff --git a/crates/workspace-server/src/server/tests/drive_api_tests.rs b/crates/workspace-server/src/server/tests/drive_api_tests.rs index 777219c7d..0d89bd926 100644 --- a/crates/workspace-server/src/server/tests/drive_api_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_api_tests.rs @@ -721,6 +721,7 @@ async fn drive_verified_upload_revoked_during_reception_cannot_commit_or_replay( token_id_hash: None, runtime_id: workers[0].runtime_id.clone(), worker_id: Some(workers[0].worker_id.clone()), + authentication_binding_id: "fixture-binding".into(), }, )); let response = app @@ -867,6 +868,7 @@ async fn drive_read_entrypoints_use_current_grants_and_cannot_fallback_to_browse context.runtime_source = Some(ServerRuntimeSource { runtime_id: workers[0].runtime_id.clone(), worker_id: Some(workers[1].worker_id.clone()), + authentication_binding_id: "fixture-binding".into(), }); assert_eq!( drive::root(&api, &context, TEST_WORKSPACE_ID) diff --git a/crates/workspace-server/src/server/tests/drive_tests.rs b/crates/workspace-server/src/server/tests/drive_tests.rs index 1d05ff7db..3b7ec65ca 100644 --- a/crates/workspace-server/src/server/tests/drive_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_tests.rs @@ -40,7 +40,7 @@ async fn cached_drive_handle_rejects_mutation_after_deletion_reservation() { }) .await .unwrap(); - let api = WorkspaceApi::new(config.clone(), store.clone()) + let api = WorkspaceApi::new_for_test(config.clone(), store.clone()) .await .unwrap(); let cached = api.drive().await.unwrap(); @@ -104,7 +104,7 @@ async fn deleting_workspace_reopens_fenced_drive_and_purges_blobs_before_feature // Use a separate embedded-runtime namespace for the first API: this test // reconstructs Drive authority in-process rather than spawning/restarting // product processes (runtime observers may retain their namespace). - let first_api = WorkspaceApi::new(config.clone(), store.clone()) + let first_api = WorkspaceApi::new_for_test(config.clone(), store.clone()) .await .unwrap(); // Merely constructing a Workspace API must not create blob storage. @@ -160,7 +160,7 @@ async fn deleting_workspace_reopens_fenced_drive_and_purges_blobs_before_feature .unwrap() .unwrap(); drop(first_api); - let recovered = WorkspaceServerApi::new(config.clone(), store.clone()); + let recovered = WorkspaceServerApi::new_for_test(config.clone(), store.clone()); let api = recovered .api_for_workspace(&config.workspace_id) .await diff --git a/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs b/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs index 02ab40e35..71efdb137 100644 --- a/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs +++ b/crates/workspace-server/src/server/tests/drive_web_roundtrip_tests.rs @@ -39,10 +39,10 @@ impl RealDriveFixture { let temp = tempfile::tempdir().unwrap(); let mut config = test_server_config(temp.path()); config.repositories.clear(); - let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); let owner = seed_test_api_token(store.as_ref(), "drive-web-owner"); let second = seed_test_api_token(store.as_ref(), "drive-web-second"); - let server_api = WorkspaceServerApi::new(config, store.clone()); + let server_api = WorkspaceServerApi::new_for_test(config, store.clone()); let server = LoopbackServer::start(workspace_server_router(server_api.clone())).await; // Exercise T-717's real repositoryless Workspace bootstrap, not a // pre-existing test Workspace with its repository merely hidden in UI. diff --git a/crates/workspace-server/src/server/tests/embedded_owned_execution_tests.rs b/crates/workspace-server/src/server/tests/embedded_owned_execution_tests.rs new file mode 100644 index 000000000..55444a031 --- /dev/null +++ b/crates/workspace-server/src/server/tests/embedded_owned_execution_tests.rs @@ -0,0 +1,197 @@ +use super::*; +use crate::db_executor::{ + DbExecutor, DbExecutorLimits, DbOperationLifetime, with_operation_lifetime, +}; +use std::{ + num::NonZeroUsize, + sync::{ + atomic::{AtomicUsize, Ordering}, + mpsc, + }, + time::Duration, +}; + +fn owner() -> DbExecutor { + DbExecutor::new(DbExecutorLimits { + workers: NonZeroUsize::new(1).unwrap(), + queued: NonZeroUsize::new(1).unwrap(), + }) + .unwrap() +} +async fn observed(future: impl std::future::Future) -> T { + tokio::time::timeout(Duration::from_secs(2), future) + .await + .expect("owned protocol stage did not progress") +} +struct Release(Option>); +impl Release { + fn release(&mut self) { + if let Some(tx) = self.0.take() { + let _ = tx.send(()); + } + } +} +impl Drop for Release { + fn drop(&mut self) { + self.release(); + } +} +struct Lifetime(Arc); +struct Retained(Arc); +impl Drop for Retained { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} +impl DbOperationLifetime for Lifetime { + fn retain(&self) -> Box { + self.0.fetch_add(1, Ordering::SeqCst); + Box::new(Retained(self.0.clone())) + } +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_embedded_endpoint_capture_keeps_actual_host_budget_off_tokio() { + let (runtime, worker, execution) = runtime_with_worker_backend(); + let (started, held) = tokio::sync::oneshot::channel(); + let (tx, rx) = mpsc::channel(); + let mut release = Release(Some(tx)); + let thread = std::thread::spawn(move || { + let _guard = execution.contexts.lock().unwrap(); + let _ = started.send(()); + let _ = rx.recv_timeout(Duration::from_secs(5)); + }); + observed(held).await.unwrap(); + let executor = owner(); + let leases = Arc::new(AtomicUsize::new(0)); + let mut capture = Box::pin(with_operation_lifetime( + Arc::new(Lifetime(leases.clone())), + connect_embedded_worker_protocol( + embedded_protocol_test_source(&runtime, &worker), + embedded_protocol_test_scope(), + executor.clone(), + ), + )); + assert!( + futures::poll!(&mut capture).is_pending(), + "endpoint Runtime lock must not run on Tokio" + ); + observed(tokio::time::sleep(Duration::from_millis(1))).await; + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + drop(capture); + assert_eq!(leases.load(Ordering::SeqCst), 1); + executor.dispatch(|| {}).unwrap(); // only the one queued slot is left + let error = executor.run(|| Ok::<_, Error>(())).await.unwrap_err(); + assert!(matches!(error, Error::DbCapacityExceeded)); + release.release(); + thread.join().unwrap(); + observed(executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_embedded_shutdown_commits_fs_receipt_after_the_pump_disappears() { + let dir = tempfile::tempdir().unwrap(); + let execution = Arc::new(DeterministicExecutionBackend::default()); + let options = worker_runtime::fs_store::FsRuntimeStoreOptions { + root: dir.path().join("runtime"), + runtime_id: "owned-protocol".into(), + display_name: None, + }; + let runtime = worker_runtime::Runtime::with_fs_store_and_execution_backend( + options.clone(), + execution.clone(), + ) + .unwrap(); + runtime.store_config_bundle(runtime_test_bundle()).unwrap(); + let worker = runtime + .create_worker_scoped(&embedded_protocol_test_scope(), runtime_create_request()) + .unwrap() + .worker_ref; + let executor = owner(); + let mut connection = connect_embedded_worker_protocol( + embedded_protocol_test_source(&runtime, &worker), + embedded_protocol_test_scope(), + executor.clone(), + ) + .await + .unwrap(); + assert!(matches!( + observed(connection.events.recv()).await.unwrap(), + protocol::Event::Snapshot { .. } + )); + let (entered, held) = tokio::sync::oneshot::channel(); + let (tx, rx) = mpsc::channel(); + let mut release = Release(Some(tx)); + let endpoint = execution + .contexts + .lock() + .unwrap() + .get(&worker) + .unwrap() + .clone(); + let gate_finished = Arc::new(std::sync::atomic::AtomicBool::new(false)); + let finished = gate_finished.clone(); + *endpoint.dispatch_hook.lock().unwrap() = Some(Box::new(move || { + let _ = entered.send(()); + let _ = rx.recv_timeout(Duration::from_secs(5)); + finished.store(true, Ordering::SeqCst); + })); + connection + .methods + .send(protocol::Method::Shutdown { + command: protocol::WorkerCommandEnvelope::new(700), + }) + .await + .unwrap(); + observed(held).await.unwrap(); // actual Runtime method, after its durable stop admission + observed(tokio::time::sleep(Duration::from_millis(1))).await; + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + assert!( + !gate_finished.load(Ordering::SeqCst), + "watchdog bailout is not Tokio progress" + ); + connection + .termination + .terminate(WorkerProtocolEnd::Disconnected); + drop(connection); + executor.dispatch(|| {}).unwrap(); + assert!(matches!( + executor.run(|| Ok::<_, Error>(())).await.unwrap_err(), + Error::DbCapacityExceeded + )); + release.release(); + observed(executor.drain()).await.unwrap(); + assert_eq!( + runtime.worker_detail(&worker).unwrap().status, + worker_runtime::catalog::WorkerStatus::Stopped + ); + // Read the actual fs-store aggregate written by the accepted method. The + // deterministic execution fixture has no restorable Profile archive, so + // restarting it would test archive recovery rather than this commit owner. + let record: serde_json::Value = serde_json::from_slice( + &std::fs::read( + options + .root + .join("workers") + .join(worker.worker_id.to_string()) + .join("worker.json"), + ) + .unwrap(), + ) + .unwrap(); + assert_eq!( + record["status"], "stopped", + "cancelled pump lost its fs-store commit: {record}" + ); + assert_ne!( + record["execution_state"]["state"], + "reconciliation_required" + ); +} diff --git a/crates/workspace-server/src/server/tests/protocol_backpressure_tests.rs b/crates/workspace-server/src/server/tests/protocol_backpressure_tests.rs new file mode 100644 index 000000000..06759a67e --- /dev/null +++ b/crates/workspace-server/src/server/tests/protocol_backpressure_tests.rs @@ -0,0 +1,934 @@ +use super::*; + +const PROTOCOL_TEST_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(2); + +// Completion replies are produced synchronously by the actual attached execution. +// Unlike flooding the fixture's 32-entry broadcast bus, this reaches the Server's +// event admission boundary without first causing unrelated Runtime relay lag. +#[tokio::test] +async fn embedded_event_overflow_terminates_without_losing_the_admitted_prefix() { + let (runtime, worker_ref, execution) = runtime_with_worker_backend(); + let mut connection = connect_embedded_worker_protocol( + embedded_protocol_test_source(&runtime, &worker_ref), + embedded_protocol_test_scope(), + HostExecutors::default().host().unwrap(), + ) + .await + .unwrap(); + + // Leave the snapshot queued. Exactly capacity - 1 completion replies fit; + // the next reply must end this connection rather than block its bridge. + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, async { + for index in 0..WORKER_PROTOCOL_EVENT_CAPACITY { + connection + .methods + .send(protocol_test_completion(&format!("overflow-{index}"))) + .await + .unwrap(); + } + assert_eq!( + connection.termination.ended().await, + WorkerProtocolEnd::EventOverflow + ); + connection.methods.closed().await; + }) + .await + .expect("full embedded event queue did not terminate the connection"); + + assert_embedded_protocol_snapshot( + next_embedded_protocol_event(&mut connection).await, + &worker_ref, + ); + for index in 0..WORKER_PROTOCOL_EVENT_CAPACITY - 1 { + assert!(matches!( + next_embedded_protocol_event(&mut connection).await, + protocol::Event::Completions { request_id: Some(id), .. } + if id == format!("overflow-{index}") + )); + } + assert!( + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, connection.events.recv()) + .await + .expect("overflowed event receiver did not close") + .is_none() + ); + assert!( + connection + .methods + .try_send(protocol_test_completion("after-overflow")) + .is_err() + ); + assert_eq!( + execution.protocol_methods().len(), + WORKER_PROTOCOL_EVENT_CAPACITY + ); +} + +#[tokio::test] +async fn embedded_protocol_delivers_snapshot_then_ordered_replies_and_live_events() { + let (runtime, worker_ref, execution) = runtime_with_worker_backend(); + let mut connection = connect_embedded_worker_protocol( + embedded_protocol_test_source(&runtime, &worker_ref), + embedded_protocol_test_scope(), + HostExecutors::default().host().unwrap(), + ) + .await + .unwrap(); + for id in ["first", "second", "third"] { + connection + .methods + .try_send(protocol_test_completion(id)) + .unwrap(); + } + assert_embedded_protocol_snapshot( + next_embedded_protocol_event(&mut connection).await, + &worker_ref, + ); + for expected in ["first", "second", "third"] { + assert!(matches!( + next_embedded_protocol_event(&mut connection).await, + protocol::Event::Completions { request_id: Some(id), .. } if id == expected + )); + } + execution.emit_worker_event( + &worker_ref, + protocol::Event::TextDelta { + text: "live-after-replies".into(), + }, + ); + assert!(matches!( + next_embedded_protocol_event(&mut connection).await, + protocol::Event::TextDelta { text } if text == "live-after-replies" + )); + assert_eq!(connection.termination.reason(), None); +} + +#[tokio::test] +async fn embedded_overflow_requires_explicit_reconnect_and_stop_ends_the_new_connection() { + let (runtime, worker_ref, execution) = runtime_with_worker_backend(); + let source = embedded_protocol_test_source(&runtime, &worker_ref); + let mut old = connect_embedded_worker_protocol( + source.clone(), + embedded_protocol_test_scope(), + HostExecutors::default().host().unwrap(), + ) + .await + .unwrap(); + assert_embedded_protocol_snapshot(next_embedded_protocol_event(&mut old).await, &worker_ref); + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, async { + for index in 0..=WORKER_PROTOCOL_EVENT_CAPACITY { + old.methods + .send(protocol_test_completion(&format!("old-{index}"))) + .await + .unwrap(); + } + assert_eq!( + old.termination.ended().await, + WorkerProtocolEnd::EventOverflow + ); + old.methods.closed().await; + }) + .await + .expect("overflowed connection remained live"); + + // Overflow ends only the attached connection, not its Worker. Reconnection + // is explicit and starts from a fresh execution snapshot, not old backlog. + let mut fresh = connect_embedded_worker_protocol( + source, + embedded_protocol_test_scope(), + HostExecutors::default().host().unwrap(), + ) + .await + .unwrap(); + assert_embedded_protocol_snapshot(next_embedded_protocol_event(&mut fresh).await, &worker_ref); + fresh + .methods + .try_send(protocol_test_completion("explicit-reconnect")) + .unwrap(); + assert!(matches!( + next_embedded_protocol_event(&mut fresh).await, + protocol::Event::Completions { request_id: Some(id), .. } if id == "explicit-reconnect" + )); + assert!( + old.methods + .try_send(protocol_test_completion("must-not-reattach")) + .is_err() + ); + assert_eq!( + old.termination.reason(), + Some(WorkerProtocolEnd::EventOverflow) + ); + assert_eq!( + execution.protocol_methods().len(), + WORKER_PROTOCOL_EVENT_CAPACITY + 2 + ); + + runtime.stop_worker(&worker_ref, None).unwrap(); + assert_eq!( + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, fresh.termination.ended()) + .await + .expect("Stop did not end the attached protocol connection"), + WorkerProtocolEnd::Disconnected + ); + assert_embedded_protocol_closed(&mut fresh).await; +} + +async fn backpressure_browser_context(api: &WorkspaceApi) -> (WorkerOperationContext, String) { + seed_test_api_token(api.store.as_ref(), "backpressure-browser"); + let token = "backpressure-browser-session"; + let token_hash = crate::auth::token_hash(token); + api.store + .create_browser_session(&BrowserSessionRecord { + token_hash: token_hash.clone(), + session_id: "backpressure-session".into(), + user_id: "user-backpressure-browser".into(), + created_at: "2026-01-01T00:00:00Z".into(), + expires_at: "2099-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + let AuthConfig::Passkey { + cookie_name, + origin, + .. + } = &api.config.auth; + let mut headers = HeaderMap::new(); + headers.insert(ORIGIN, origin.parse().unwrap()); + headers.insert( + axum::http::header::COOKIE, + format!("{cookie_name}={token}").parse().unwrap(), + ); + let actor = resolve_actor(&ServerAuthApi::from(api), &headers) + .await + .unwrap() + .expect("fixture browser session must authenticate"); + ( + WorkerOperationContext::Browser { + headers, + source: authenticated_browser_input_source(&actor), + }, + token_hash, + ) +} + +async fn undrained_protocol_connection() -> ( + WorkspaceWorkerProtocolConnection, + tokio::sync::mpsc::Receiver, +) { + let (receiver_sender, receiver) = tokio::sync::oneshot::channel(); + let connection = spawn_worker_protocol_pump(move |methods, events| async move { + receiver_sender.send(methods).unwrap(); + // Keep the event endpoint live until common termination cancels this + // fixture pump. Method consumption is controlled solely by the test. + let _events = events; + std::future::pending::().await + }); + let receiver = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, receiver) + .await + .expect("fixture pump did not expose its undrained method endpoint") + .unwrap(); + (connection, receiver) +} + +async fn full_protocol_method_queue() -> ( + WorkspaceWorkerProtocolConnection, + tokio::sync::mpsc::Receiver, +) { + let (connection, receiver) = undrained_protocol_connection().await; + for index in 0..WORKER_PROTOCOL_METHOD_CAPACITY { + connection + .methods + .try_send(protocol_test_completion(&format!("queued-{index}"))) + .unwrap(); + } + (connection, receiver) +} + +#[tokio::test] +async fn browser_method_queue_overflow_fails_fast_and_does_not_pin_session_cleanup() { + let workspace = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(workspace.path()).await; + let identity = spawn_ticket_check_source(&api, "method-overflow"); + let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); + let (context, _) = backpressure_browser_context(&api).await; + let (connection, mut receiver) = full_protocol_method_queue().await; + let termination = connection.termination.clone(); + let sender = + WorkspaceWorkerMethodSender::new(worker.clone(), connection.methods, termination.clone()); + let _events = connection.events; + + let result = tokio::time::timeout( + PROTOCOL_TEST_TIMEOUT, + sender.send( + &context, + protocol::Method::Cancel { + command: protocol::WorkerCommandEnvelope::new(801), + }, + ), + ) + .await + .expect("method admission blocked while holding the session guard"); + assert!(result.is_err(), "full method queue must reject Cancel too"); + assert_eq!( + termination.reason(), + Some(WorkerProtocolEnd::MethodOverflow) + ); + assert_eq!(receiver.len(), WORKER_PROTOCOL_METHOD_CAPACITY); + assert!(matches!( + receiver.try_recv().unwrap(), + protocol::Method::ListCompletions { request_id: Some(id), .. } if id == "queued-0" + )); + assert!( + sender + .send( + &context, + protocol_test_completion("must-not-retry-after-drain") + ) + .await + .is_err() + ); + assert_eq!(receiver.len(), WORKER_PROTOCOL_METHOD_CAPACITY - 1); + + let stopped = tokio::time::timeout( + PROTOCOL_TEST_TIMEOUT, + worker.stop( + &WorkerOperationContext::Backend, + WorkerLifecycleRequest { + reason: Some("cleanup-after-method-overflow".into()), + ticket_assignment: None, + }, + ), + ) + .await + .expect("full method queue pinned Stop's session cleanup") + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + assert!(execution.protocol_methods().is_empty()); + for index in 1..WORKER_PROTOCOL_METHOD_CAPACITY { + assert!(matches!( + receiver.try_recv().unwrap(), + protocol::Method::ListCompletions { request_id: Some(id), .. } + if id == format!("queued-{index}") + )); + } + assert!(receiver.try_recv().is_err()); +} + +#[tokio::test] +async fn revoked_browser_credentials_reject_before_method_overflow_admission() { + let workspace = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(workspace.path()).await; + let identity = spawn_ticket_check_source(&api, "revoked-full-queue"); + let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); + let (context, token_hash) = backpressure_browser_context(&api).await; + let (connection, receiver) = full_protocol_method_queue().await; + let termination = connection.termination.clone(); + let sender = WorkspaceWorkerMethodSender::new(worker, connection.methods, termination.clone()); + let _events = connection.events; + assert!( + api.store + .revoke_browser_session(&token_hash, &now_registry_timestamp()) + .unwrap() + ); + + let result = tokio::time::timeout( + PROTOCOL_TEST_TIMEOUT, + sender.send(&context, protocol_test_completion("unauthorized")), + ) + .await + .expect("revoked credentials waited for method capacity"); + assert!(matches!(result, Err(Error::WorkspacePermissionDenied(_)))); + assert_eq!(termination.reason(), None); + assert_eq!(receiver.len(), WORKER_PROTOCOL_METHOD_CAPACITY); + assert!(execution.protocol_methods().is_empty()); +} + +#[tokio::test] +async fn cancelling_pending_browser_admission_does_not_enqueue_or_hold_the_session_guard() { + let workspace = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(workspace.path()).await; + let identity = spawn_ticket_check_source(&api, "cancel-pending-admission"); + let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); + let (context, _) = backpressure_browser_context(&api).await; + let (connection, mut receiver) = undrained_protocol_connection().await; + let termination = connection.termination.clone(); + let sender = WorkspaceWorkerMethodSender::new(worker, connection.methods, termination.clone()); + let _events = connection.events; + let session_lock = current_worker_session_lock(&api, &identity); + let guard = session_lock.lock().await; + { + let admission = sender.send(&context, protocol_test_completion("cancelled-admission")); + tokio::pin!(admission); + assert!(futures::poll!(admission.as_mut()).is_pending()); + // Dropping a blocked admission future must not schedule a detached retry. + } + drop(guard); + assert!(receiver.try_recv().is_err()); + let guard = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session_lock.lock()) + .await + .expect("cancelled admission retained the session guard"); + close_current_worker_session_locked(&api, &identity) + .await + .unwrap(); + drop(guard); + + let command = protocol::WorkerCommandEnvelope::new(802); + sender + .send(&context, protocol::Method::Cancel { command }) + .await + .unwrap(); + assert!(matches!( + receiver.try_recv().unwrap(), + protocol::Method::Cancel { command: admitted } if admitted == command + )); + assert!(receiver.try_recv().is_err()); + assert_eq!(termination.reason(), None); + assert!(execution.protocol_methods().is_empty()); +} + +// In-process browser transport: one real JSON method frame, then an idle +// connection. Its writes are immediately ready, isolating authorization waits +// from transport congestion and without crossing a complete product flow. +struct AdmissionCancellationSocket { + inbound: Option, + written: Vec, +} + +impl futures::Stream for AdmissionCancellationSocket { + type Item = std::result::Result; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + match self.inbound.take() { + Some(message) => std::task::Poll::Ready(Some(Ok(message))), + None => std::task::Poll::Pending, + } + } +} + +impl futures::Sink for AdmissionCancellationSocket { + type Error = std::convert::Infallible; + + fn poll_ready( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn start_send( + mut self: std::pin::Pin<&mut Self>, + message: WsMessage, + ) -> std::result::Result<(), Self::Error> { + self.written.push(message); + Ok(()) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } +} + +#[tokio::test] +async fn browser_protocol_termination_cancels_admission_waiting_for_session_authority() { + let workspace = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(workspace.path()).await; + let identity = spawn_ticket_check_source(&api, "browser-authorization-cancellation"); + let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); + let (context, _) = backpressure_browser_context(&api).await; + let (connection, mut receiver) = undrained_protocol_connection().await; + let termination = connection.termination.clone(); + let sender = WorkspaceWorkerMethodSender::new(worker, connection.methods, termination.clone()); + let connection = worker_operations::WorkspaceWorkerConnection { + sender, + events: connection.events, + }; + let session_lock = current_worker_session_lock(&api, &identity); + let guard = session_lock.lock().await; + let method = protocol::Method::Cancel { + command: protocol::WorkerCommandEnvelope::new(803), + }; + let mut socket = AdmissionCancellationSocket { + inbound: Some(WsMessage::Text( + protocol::stream::encode_method(&method).unwrap().into(), + )), + written: Vec::new(), + }; + { + let session = workspace_worker_protocol_session(connection, &mut socket, context); + tokio::pin!(session); + // SQLite browser authorization is synchronous; this poll consumes the + // frame and reaches the deliberately held session lock. + assert!(futures::poll!(session.as_mut()).is_pending()); + termination.terminate(WorkerProtocolEnd::Disconnected); + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session) + .await + .expect("terminal connection did not cancel pending browser admission"); + } + assert!(matches!(socket.written.last(), Some(WsMessage::Close(_)))); + assert!(receiver.try_recv().is_err()); + drop(guard); + let _guard = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session_lock.lock()) + .await + .expect("cancelled browser authorization pinned session cleanup"); + assert!(execution.protocol_methods().is_empty()); +} + +struct BackpressureTestServer(tokio::task::JoinHandle<()>); + +impl Drop for BackpressureTestServer { + fn drop(&mut self) { + self.0.abort(); + } +} + +#[tokio::test] +async fn browser_protocol_session_ends_on_full_method_queue_without_blocking_cleanup() { + let workspace = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(workspace.path()).await; + let identity = spawn_ticket_check_source(&api, "browser-session-overflow"); + let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); + let (context, _) = backpressure_browser_context(&api).await; + let (connection, receiver) = full_protocol_method_queue().await; + let termination = connection.termination.clone(); + let sender = WorkspaceWorkerMethodSender::new(worker, connection.methods, termination.clone()); + let events = connection.events; + let (session_done, finished) = tokio::sync::oneshot::channel(); + let pending = Arc::new(std::sync::Mutex::new(Some(( + worker_operations::WorkspaceWorkerConnection { sender, events }, + session_done, + )))); + let app = Router::new().route( + "/protocol/ws", + get(move |ws: WebSocketUpgrade| { + let pending = pending.clone(); + let context = context.clone(); + async move { + ws.on_upgrade(move |socket| async move { + let (connection, session_done) = pending.lock().unwrap().take().unwrap(); + workspace_worker_protocol_session(connection, socket, context).await; + let _ = session_done.send(()); + }) + } + }), + ); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + let _server = BackpressureTestServer(tokio::spawn(async move { + axum::serve(listener, app).await.unwrap(); + })); + let (mut socket, _) = connect_async(format!("ws://{address}/protocol/ws")) + .await + .unwrap(); + tokio::time::timeout( + PROTOCOL_TEST_TIMEOUT, + send_protocol_test_ws_method(&mut socket, &protocol_test_completion("browser-overflow")), + ) + .await + .expect("browser method frame did not reach the loopback transport"); + assert_eq!( + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, termination.ended()) + .await + .expect("browser session did not report method overflow"), + WorkerProtocolEnd::MethodOverflow + ); + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, finished) + .await + .expect("browser protocol session did not finish after overflow") + .expect("browser protocol session dropped its completion signal"); + let session_lock = current_worker_session_lock(&api, &identity); + let _guard = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session_lock.lock()) + .await + .expect("browser method overflow pinned session cleanup"); + close_current_worker_session_locked(&api, &identity) + .await + .unwrap(); + assert_eq!(receiver.len(), WORKER_PROTOCOL_METHOD_CAPACITY); + assert!(execution.protocol_methods().is_empty()); +} + +// Accepting an output frame is not proof of delivery: this browser transport +// never finishes flushing, including the final best-effort Close frame. +struct StalledBrowserOutput { + entered: Option>, + dropped: Option>, + written: Arc>>, +} + +impl futures::Stream for StalledBrowserOutput { + type Item = std::result::Result; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } +} + +impl futures::Sink for StalledBrowserOutput { + type Error = std::convert::Infallible; + + fn poll_ready( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Ready(Ok(())) + } + + fn start_send( + mut self: std::pin::Pin<&mut Self>, + message: WsMessage, + ) -> std::result::Result<(), Self::Error> { + self.written.lock().unwrap().push(message); + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); + } + Ok(()) + } + + fn poll_flush( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll> { + std::task::Poll::Pending + } +} + +impl Drop for StalledBrowserOutput { + fn drop(&mut self) { + if let Some(dropped) = self.dropped.take() { + let _ = dropped.send(()); + } + } +} + +fn assert_bounded_unknown_delivery_close(messages: &[WsMessage]) { + let Some(WsMessage::Close(Some(frame))) = messages.last() else { + panic!("terminal browser session must attempt an explicit Close frame: {messages:?}"); + }; + assert_eq!(frame.code, 1011); + assert!(frame.reason.contains("delivery unknown"), "{frame:?}"); + assert!(frame.reason.contains("no retry"), "{frame:?}"); + assert!( + frame.reason.len() < 123, + "Close reason exceeds its wire bound: {frame:?}" + ); +} + +async fn assert_same_worker_reconnect_and_stop( + worker: &WorkspaceWorker, + context: &WorkerOperationContext, +) { + let mut fresh = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, worker.connect_protocol(context)) + .await + .expect("terminated transport retained same Worker's connection authority") + .unwrap(); + assert!(matches!( + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, fresh.events.recv()) + .await + .expect("same Worker reconnect did not deliver a fresh snapshot"), + Some(protocol::Event::Snapshot { .. }) + )); + let stopped = tokio::time::timeout( + PROTOCOL_TEST_TIMEOUT, + worker.stop( + &WorkerOperationContext::Backend, + WorkerLifecycleRequest { + reason: Some("cleanup-after-stalled-protocol".into()), + ticket_assignment: None, + }, + ), + ) + .await + .expect("terminated transport pinned same Worker's Stop/session cleanup") + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + assert_eq!( + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, fresh.sender.termination.ended()) + .await + .expect("Stop did not terminate the fresh same Worker connection"), + WorkerProtocolEnd::Disconnected + ); +} + +async fn assert_stalled_browser_output_reclaimed(explicit_termination: bool) { + let workspace = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(workspace.path()).await; + let identity = spawn_ticket_check_source(&api, "stalled-browser-output"); + let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); + let (context, _) = backpressure_browser_context(&api).await; + // Use the real attached embedded execution snapshot as browser output. + let raw = connect_workspace_worker_protocol(&api, &identity, None) + .await + .unwrap(); + let methods = raw.methods.clone(); + let termination = raw.termination; + let connection = worker_operations::WorkspaceWorkerConnection { + sender: WorkspaceWorkerMethodSender::new(worker.clone(), raw.methods, termination.clone()), + events: raw.events, + }; + let (entered_sender, entered) = tokio::sync::oneshot::channel(); + let (dropped_sender, dropped) = tokio::sync::oneshot::channel(); + let written = Arc::new(std::sync::Mutex::new(Vec::new())); + let socket = StalledBrowserOutput { + entered: Some(entered_sender), + dropped: Some(dropped_sender), + written: written.clone(), + }; + let started = tokio::time::Instant::now(); + { + let session = workspace_worker_protocol_session(connection, socket, context.clone()); + tokio::pin!(session); + assert!(futures::poll!(session.as_mut()).is_pending()); + // Poll the real session alongside its output-acceptance oneshot rather + // than assuming how many scheduler turns the attached pump needs. + let output_accepted = async { + tokio::select! { + result = entered => result.expect("browser sink dropped before receiving output"), + _ = session.as_mut() => panic!("browser session ended before accepting output"), + } + }; + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, output_accepted) + .await + .expect("attached snapshot never reached the stalled browser output"); + { + let messages = written.lock().unwrap(); + assert_eq!(messages.len(), 1); + let WsMessage::Text(text) = &messages[0] else { + panic!("first browser output must be the attached snapshot"); + }; + assert!(matches!( + protocol::stream::decode_event(text.as_str()).unwrap(), + protocol::Event::Snapshot { .. } + )); + } + + if explicit_termination { + termination.terminate(WorkerProtocolEnd::Disconnected); + } else { + tokio::time::advance(std::time::Duration::from_secs(9)).await; + assert!(futures::poll!(session.as_mut()).is_pending()); + assert_eq!(termination.reason(), None); + tokio::time::advance(std::time::Duration::from_secs(1)).await; + } + assert!(futures::poll!(session.as_mut()).is_pending()); + assert_eq!( + termination.reason(), + Some(if explicit_termination { + WorkerProtocolEnd::Disconnected + } else { + WorkerProtocolEnd::TransportTimeout + }), + "browser end must publish the concrete common termination cause" + ); + { + let messages = written.lock().unwrap(); + assert_eq!(messages.len(), 2, "must not retry uncertain browser output"); + assert_bounded_unknown_delivery_close(&messages); + } + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, methods.closed()) + .await + .expect("browser termination did not cancel its actual attached pump"); + let session_lock = current_worker_session_lock(&api, &identity); + let guard = tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session_lock.lock()) + .await + .expect("blocked browser Close pinned session ownership"); + close_current_worker_session_locked(&api, &identity) + .await + .unwrap(); + drop(guard); + + // The terminal Close itself is stalled. It must get a separate bounded + // attempt, not become a second unbounded output wait. + tokio::time::advance(std::time::Duration::from_secs(9)).await; + assert!(futures::poll!(session.as_mut()).is_pending()); + tokio::time::advance(std::time::Duration::from_secs(1)).await; + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, session.as_mut()) + .await + .expect("stalled terminal Close did not reach its deadline"); + } + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, dropped) + .await + .expect("completed browser session retained its stalled sink") + .expect("browser sink dropped without its ownership signal"); + assert_eq!(written.lock().unwrap().len(), 2); + assert_eq!( + tokio::time::Instant::now() - started, + std::time::Duration::from_secs(if explicit_termination { 10 } else { 20 }) + ); + assert!(execution.protocol_methods().is_empty()); + // SQLite jobs run on external threads; paused Tokio time cannot observe their + // progress and may auto-advance a reconnect timeout before SQL completes. + tokio::time::resume(); + assert_same_worker_reconnect_and_stop(&worker, &context).await; +} + +#[tokio::test(start_paused = true)] +async fn browser_blocked_output_deadline_ends_pump_and_bounds_the_terminal_close() { + assert_stalled_browser_output_reclaimed(false).await; +} + +#[tokio::test(start_paused = true)] +async fn browser_common_termination_preempts_blocked_output_and_bounds_the_terminal_close() { + assert_stalled_browser_output_reclaimed(true).await; +} + +#[tokio::test] +async fn embedded_disconnect_and_last_owner_drop_release_the_execution_event_subscription() { + for explicit_termination in [false, true] { + let (runtime, worker_ref, execution) = runtime_with_worker_backend(); + let endpoint = execution + .contexts + .lock() + .unwrap() + .get(&worker_ref) + .cloned() + .unwrap(); + let mut connection = connect_embedded_worker_protocol( + embedded_protocol_test_source(&runtime, &worker_ref), + embedded_protocol_test_scope(), + HostExecutors::default().host().unwrap(), + ) + .await + .unwrap(); + assert_embedded_protocol_snapshot( + next_embedded_protocol_event(&mut connection).await, + &worker_ref, + ); + assert_eq!(endpoint.events.receiver_count(), 1); + let WorkspaceWorkerProtocolConnection { + methods, + mut events, + termination, + } = connection; + let last_owner = termination.clone(); + drop(termination); + assert!(!methods.is_closed()); + if explicit_termination { + last_owner.terminate(WorkerProtocolEnd::Disconnected); + // Keep the owner alive until watch cancellation is observed so + // abort-on-drop cannot hide a broken explicit-disconnect path. + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, methods.closed()) + .await + .expect("embedded explicit disconnect did not reclaim its pump"); + } + // Both channel endpoints remain held. Without explicit termination, + // only dropping the last client owner can cancel this attached pump. + drop(last_owner); + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, methods.closed()) + .await + .expect("embedded last-owner drop did not reclaim its method pump"); + assert!( + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, events.recv()) + .await + .expect("reclaimed embedded event endpoint stayed open") + .is_none() + ); + // The fixture's execution relay notices its dropped receiver on its + // next event. Wake that relay once, then observe subscription closure. + let _ = endpoint.events.send(protocol::Event::TextDelta { + text: "execution-relay-close-probe".into(), + }); + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, endpoint.events.closed()) + .await + .expect("dropped embedded transport retained its execution event subscription"); + assert_eq!(endpoint.events.receiver_count(), 0); + assert!(execution.protocol_methods().is_empty()); + runtime.stop_worker(&worker_ref, None).unwrap(); + } +} + +#[tokio::test] +async fn stalled_remote_bridge_browser_cancel_overflow_reclaims_wire_and_allows_same_worker_progress() + { + let workspace = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(workspace.path()).await; + let identity = spawn_ticket_check_source(&api, "remote-browser-overflow"); + let worker = WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); + let (context, _) = backpressure_browser_context(&api).await; + let (raw, entered, dropped, sent) = super::worker_protocol_tests::stalled_remote_bridge(); + raw.methods + .try_send(protocol_test_completion("wire-delivery-unknown")) + .unwrap(); + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, entered) + .await + .expect("actual remote pump did not enter its stalled wire send") + .unwrap(); + for index in 0..WORKER_PROTOCOL_METHOD_CAPACITY { + raw.methods + .try_send(protocol_test_completion(&format!("remote-queued-{index}"))) + .unwrap(); + } + let methods = raw.methods.clone(); + let termination = raw.termination; + let connection = worker_operations::WorkspaceWorkerConnection { + sender: WorkspaceWorkerMethodSender::new(worker.clone(), raw.methods, termination.clone()), + events: raw.events, + }; + let cancel = protocol::Method::Cancel { + command: protocol::WorkerCommandEnvelope::new(804), + }; + let mut socket = AdmissionCancellationSocket { + inbound: Some(WsMessage::Text( + protocol::stream::encode_method(&cancel).unwrap().into(), + )), + written: Vec::new(), + }; + // This real browser session decodes Cancel and passes the authenticated + // context through the actual method sender into the saturated remote pump. + tokio::time::timeout( + PROTOCOL_TEST_TIMEOUT, + workspace_worker_protocol_session(connection, &mut socket, context.clone()), + ) + .await + .expect("browser Cancel waited for the stalled remote wire instead of failing admission"); + assert_eq!( + termination.reason(), + Some(WorkerProtocolEnd::MethodOverflow) + ); + assert!(socket.inbound.is_none()); + assert_bounded_unknown_delivery_close(&socket.written); + tokio::time::timeout(PROTOCOL_TEST_TIMEOUT, dropped) + .await + .expect("method overflow did not drop the stalled remote sink") + .unwrap(); + assert!(methods.is_closed()); + { + let messages = sent.lock().unwrap(); + assert_eq!( + messages.len(), + 1, + "must not retry or drain unknown remote delivery" + ); + let TungsteniteMessage::Text(text) = &messages[0] else { + panic!("remote pump must send an actual encoded method frame"); + }; + assert!(matches!( + decode_method(text.as_str()).unwrap(), + protocol::Method::ListCompletions { request_id: Some(id), .. } + if id == "wire-delivery-unknown" + )); + } + assert!(execution.protocol_methods().is_empty()); + assert_same_worker_reconnect_and_stop(&worker, &context).await; +} diff --git a/crates/workspace-server/src/server/tests/subject_spawn_tests.rs b/crates/workspace-server/src/server/tests/subject_spawn_tests.rs index b54c4bdad..f22bc4a9d 100644 --- a/crates/workspace-server/src/server/tests/subject_spawn_tests.rs +++ b/crates/workspace-server/src/server/tests/subject_spawn_tests.rs @@ -1,34 +1,45 @@ use super::*; -#[tokio::test] -async fn subject_session_callback_completes_while_create_and_deletion_are_waiting() { +#[tokio::test(flavor = "current_thread")] +async fn subject_callbacks_finish_before_waiting_host_parents_release_capacity() { let workspace = tempfile::tempdir().unwrap(); let mut api = test_api(workspace.path()).await; let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); configure_runtime_request_auth(&mut api, &identity, "runtime-test"); let subjektiv = open_subjektiv_store(&api).unwrap(); - let subject = subjektiv - .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) - .unwrap(); - let singleton_key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap(); let memory = api .config_store .get_workspace_memory_settings(TEST_WORKSPACE_ID) .unwrap(); - let reserved = api - .config_store - .reserve_worker_create( - TEST_WORKSPACE_ID, - "runtime-test", - "subject-start", - &"f".repeat(64), - Some(&singleton_key), - &memory, - ) - .unwrap(); - let worker_id = reserved.worker_id.to_string(); - let server = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); - // Reuse the fixture API, but exercise the real outer dispatcher and both auth layers. + let mut callbacks = Vec::new(); + for index in 0..2 { + let subject = subjektiv + .create_subject( + crate::subjektiv::SubjectRole::new(format!("companion-{index}")).unwrap(), + ) + .unwrap(); + let singleton_key = crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap(); + let reserved = api + .config_store + .reserve_worker_create( + TEST_WORKSPACE_ID, + "runtime-test", + &format!("subject-start-{index}"), + &"f".repeat(64), + Some(&singleton_key), + &memory, + ) + .unwrap(); + callbacks.push(( + subject.id, + reserved.worker_id.to_string(), + session_store::new_session_id().to_string(), + )); + } + let server = WorkspaceServerApi::new_for_test(api.config.clone(), api.store.clone()); + // Real dispatcher, source proof authentication, live singleton authority and + // attribution SQL. Only Runtime's create/restore wait is modeled below; no + // product process or complete create endpoint is spawned by this route test. server .apis .lock() @@ -39,61 +50,210 @@ async fn subject_session_callback_completes_while_create_and_deletion_are_waitin build_inner_router(api.clone()), ); let gate = server.admission_for_workspace(TEST_WORKSPACE_ID).await; - let create = gate.admit(false).unwrap(); - let mut deletion = Box::pin(gate.lock_deletion()); - assert!(futures::poll!(&mut deletion).is_pending()); + let host = api.host_executors.host().unwrap(); // production 2 workers/16 queued + let mut parents = Vec::new(); + let mut finish_callbacks = Vec::new(); + for _ in 0..2 { + let create = gate.admit(false).unwrap(); + let (entered, ready) = tokio::sync::oneshot::channel(); + let (finish_callback, callback_reply) = std::sync::mpsc::channel(); + finish_callbacks.push(finish_callback); + parents.push(tokio::spawn(run_host_workflow(host.clone(), async move { + let _create = create; + entered.send(()).unwrap(); + // The actual Host parent cannot finish until its mandatory callbacks + // return. Timeout/disconnection is failure cleanup, never success. + callback_reply + .recv_timeout(std::time::Duration::from_secs(5)) + .map_err(|error| { + Error::InvalidInput(format!("Runtime callback did not complete: {error}")) + })?; + Ok::<_, Error>(()) + }))); + tokio::time::timeout(std::time::Duration::from_secs(2), ready) + .await + .unwrap() + .unwrap(); + } let app = workspace_server_router(server); let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions"); - let session_id = session_store::new_session_id().to_string(); - let body = serde_json::to_vec(&json!({ "session_id": session_id, "create_if_missing": true })) - .unwrap(); - let response = tokio::time::timeout( - std::time::Duration::from_secs(2), - app.clone().oneshot(runtime_source_request( - &identity, - Some(&worker_id), - "POST", - &path, - body.clone(), - )), - ) - .await - .expect("Session attribution must not wait for its own Worker creation") - .unwrap(); - let status = response.status(); - let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); - assert_eq!(status, StatusCode::OK, "{response_body:?}"); - let response: Value = serde_json::from_slice(&response_body).unwrap(); - assert_eq!(response["subject_id"], subject.id); - assert_eq!(response["session_id"], session_id); - let stored = subjektiv.session_attribution(&session_id).unwrap().unwrap(); - assert_eq!(stored.worker_id, worker_id); - assert_eq!(stored.subject_id, subject.id); - - // The callback lane does not bypass authentication. - let response = tokio::time::timeout( - std::time::Duration::from_secs(2), - app.clone().oneshot( - Request::builder() - .method("POST") - .uri(&path) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(body)) + let memory_path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/memory"); + for (_, worker_id, _) in &callbacks { + // Resident context is also a pure Subject callback used by Runtime + // bootstrap/restore. It must not reenter the waiting Host lane either. + let response = tokio::time::timeout( + std::time::Duration::from_secs(2), + app.clone().oneshot(runtime_source_request( + &identity, + Some(worker_id), + "POST", + &memory_path, + serde_json::to_vec(&server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::ResidentContext( + Default::default(), + ), + }) .unwrap(), - ), - ) - .await - .unwrap() - .unwrap(); - assert_eq!(response.status(), StatusCode::UNAUTHORIZED); + )), + ) + .await + .expect("resident context must not wait for its Host parents") + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, StatusCode::OK, "{body:?}"); + let resident: server_api::SubjektivMemoryBackendResponse = + serde_json::from_slice(&body).unwrap(); + assert!(matches!( + resident, + server_api::SubjektivMemoryBackendResponse::ResidentContext(_) + )); + } + // Resident memory is not an allowed deletion-drain callback. Preserve that + // admission fence; only the exact Session attribution route reenters below. + let mut deletion = Box::pin(gate.lock_deletion()); assert!(futures::poll!(&mut deletion).is_pending()); - drop(create); + for (subject_id, worker_id, session_id) in &callbacks { + let body = + serde_json::to_vec(&json!({ "session_id": session_id, "create_if_missing": true })) + .unwrap(); + let response = tokio::time::timeout( + std::time::Duration::from_secs(2), + app.clone().oneshot(runtime_source_request( + &identity, + Some(worker_id), + "POST", + &path, + body, + )), + ) + .await + .expect("Session attribution must not wait for its Host parents") + .unwrap(); + let status = response.status(); + let response_body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, StatusCode::OK, "{response_body:?}"); + let response: Value = serde_json::from_slice(&response_body).unwrap(); + assert_eq!(response["subject_id"], *subject_id); + assert_eq!(response["session_id"], *session_id); + let stored = subjektiv.session_attribution(session_id).unwrap().unwrap(); + assert_eq!(stored.worker_id, *worker_id); + assert_eq!(stored.subject_id, *subject_id); + } + // Neither actual parent nor its admission lease was released to make these + // callbacks succeed. Only durable attribution completion releases them. + assert!(parents.iter().all(|parent| !parent.is_finished())); + assert!(futures::poll!(&mut deletion).is_pending()); + for finish_callback in finish_callbacks { + finish_callback.send(()).unwrap(); + } + for parent in parents { + parent.await.unwrap().unwrap(); + } + host.drain().await.unwrap(); + api.store.db_executor().drain().await.unwrap(); let _deletion = tokio::time::timeout(std::time::Duration::from_secs(2), deletion) .await .unwrap(); assert!(gate.admit(true).is_none()); } +#[tokio::test] +async fn consolidation_subject_callbacks_are_denied_without_runtime_lookup() { + let workspace = tempfile::tempdir().unwrap(); + let mut api = test_api(workspace.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-test").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-test"); + let subjektiv = open_subjektiv_store(&api).unwrap(); + let subject = subjektiv + .create_subject(crate::subjektiv::SubjectRole::new("companion").unwrap()) + .unwrap(); + let key = format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{}", subject.id); + let memory = api + .config_store + .get_workspace_memory_settings(TEST_WORKSPACE_ID) + .unwrap(); + let reservation = api + .config_store + .reserve_worker_create( + TEST_WORKSPACE_ID, + "runtime-test", + "consolidation-start", + &"f".repeat(64), + Some(&key), + &memory, + ) + .unwrap(); + // runtime-test has no Worker adapter: authority denial must not try to look + // up its profile/tags through Runtime before rejecting this disallowed role. + let server = WorkspaceServerApi::new_for_test(api.config.clone(), api.store.clone()); + server + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + server + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(server); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions"); + let worker_id = reservation.worker_id.to_string(); + let session_id = session_store::new_session_id().to_string(); + for (callback_path, callback_body) in [ + ( + path.clone(), + serde_json::to_vec(&json!({"session_id": session_id, "create_if_missing": true})) + .unwrap(), + ), + ( + format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/memory"), + serde_json::to_vec(&server_api::SubjektivMemoryBackendRequest { + operation: server_api::SubjektivMemoryBackendOperation::ResidentContext( + Default::default(), + ), + }) + .unwrap(), + ), + ] { + let response = app + .clone() + .oneshot(runtime_source_request( + &identity, + Some(&worker_id), + "POST", + &callback_path, + callback_body, + )) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, StatusCode::FORBIDDEN, "{callback_path}: {body:?}"); + } + assert!( + subjektiv + .session_attribution(&session_id) + .unwrap() + .is_none() + ); + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(path) + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + json!({"session_id": session_id, "create_if_missing": true}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); +} + #[tokio::test] async fn workspace_deletion_waits_for_session_attribution_and_fences_both_lanes() { let gate = Arc::new(WorkspaceAdmission::default()); diff --git a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs index adc0d79ab..3e1ff2ddf 100644 --- a/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs +++ b/crates/workspace-server/src/server/tests/subjektiv_jobs_tests.rs @@ -53,6 +53,7 @@ fn context(worker: &RuntimeWorkerRef) -> server_api::ServerRequestContext { runtime_source: Some(server_api::ServerRuntimeSource { runtime_id: worker.runtime_id.clone(), worker_id: Some(worker.worker_id.clone()), + authentication_binding_id: "fixture-binding".into(), }), origin: None, transport_headers: vec![], diff --git a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs index 113d6d056..f50cc7e95 100644 --- a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs @@ -47,7 +47,6 @@ impl EvidenceApiFixture { assert!(!temp.path().join(".git").exists()); let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("T716 remote immutable evidence"); - input.workflow_state = Some(TicketWorkflowState::InProgress); input.targets = vec![ticket::TicketTarget { repository_key: "test-repository".into(), ref_selector: Some("develop".into()), @@ -191,12 +190,16 @@ impl EvidenceApiFixture { .unwrap() } - fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion { - ticket::TicketCompletion { - operation_key: "evidence-ticket-completion".into(), + fn closure(&self) -> ticket::TicketCaseUpdate { + ticket::TicketCaseUpdate { + operation_key: "evidence-ticket-closure".into(), expected_content_digest: self.content_digest(), - expected_state: TicketWorkflowState::InProgress, - reason: "Implementation judged complete independently of MR attestation".into(), + expected_state: ticket::TicketState::Open, + expected_closure_operation_key: None, + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: "Implementation judged complete independently of MR attestation".into(), references: Vec::new(), author: Some("fixture".into()), } @@ -297,14 +300,14 @@ impl EvidenceApiFixture { } #[tokio::test] -async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() { +async fn closed_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() { let fixture = EvidenceApiFixture::new().await; let approval = fixture.approve("integration"); let merge = fixture.integrate(&approval); let content_digest = fixture.content_digest(); let completion = fixture .backend - .complete(&fixture.ticket_id, fixture.completion(&approval)) + .update_case(&fixture.ticket_id, fixture.closure()) .unwrap(); assert_eq!(ticket::ticket_content_digest(&completion), content_digest); assert!( @@ -325,7 +328,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_ ); let shown = fixture.show().await; - assert_eq!(shown.state, "done"); + assert_eq!(shown.state.as_str(), "closed"); assert_eq!(shown.content_digest, content_digest); assert!( shown.evidence.complete_for_integration, @@ -395,15 +398,15 @@ async fn latest_postmerge_approval_requires_exact_content_and_stored_source_resu .contains(&"review_after_rescope".into()) ); fixture.assert_filters(false, true, true).await; - let mut outdated_completion = fixture.completion(&integration); - outdated_completion.expected_content_digest = integration.ticket_content_digest.clone(); + let mut outdated_closure = fixture.closure(); + outdated_closure.expected_content_digest = integration.ticket_content_digest.clone(); assert!( fixture .backend - .complete(&fixture.ticket_id, outdated_completion) + .update_case(&fixture.ticket_id, outdated_closure) .is_err() ); - assert_eq!(fixture.show().await.state, "inprogress"); + assert_eq!(fixture.show().await.state.as_str(), "open"); let latest = fixture.approve("latest-content"); assert_eq!(latest.created_at, integration.created_at); @@ -467,7 +470,7 @@ async fn latest_postmerge_approval_requires_exact_content_and_stored_source_resu assert!(wrong.evidence.approved_current_subject); assert!(!wrong.evidence.complete_for_integration); fixture.assert_filters(false, true, true).await; - assert_eq!(fixture.show().await.state, "inprogress"); + assert_eq!(fixture.show().await.state.as_str(), "open"); let repaired = fixture.approve("exact-snapshot"); fixture.assert_filters(true, false, false).await; @@ -488,10 +491,10 @@ async fn latest_postmerge_approval_requires_exact_content_and_stored_source_resu assert_eq!(detail.target.resolved_ref.as_deref(), Some(EVIDENCE_TARGET)); let completion = fixture .backend - .complete(&fixture.ticket_id, fixture.completion(&repaired)) + .update_case(&fixture.ticket_id, fixture.closure()) .unwrap(); assert_eq!(ticket::ticket_content_digest(&completion), content_digest); - assert_eq!(fixture.show().await.state, "done"); + assert_eq!(fixture.show().await.state.as_str(), "closed"); fixture.assert_filters(true, false, false).await; } @@ -645,14 +648,18 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta .unwrap(); fixture .backend - .update_state( + .update_case( &fixture.ticket_id, - ticket::TicketStateUpdate { + ticket::TicketCaseUpdate { operation_key: "close-with-open-mr".into(), expected_content_digest: fixture.content_digest(), - expected_state: TicketWorkflowState::InProgress, - state: TicketWorkflowState::Closed, - reason: "The request was withdrawn; no integration or approval is asserted".into(), + expected_state: ticket::TicketState::Open, + expected_closure_operation_key: None, + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + explanation: "The request was withdrawn; no integration or approval is asserted" + .into(), references: Vec::new(), author: Some("workspace-user".into()), }, @@ -667,13 +674,13 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta serde_json::to_value(before).unwrap() ); let shown = fixture.show().await; - assert_eq!(shown.state, "closed"); + assert_eq!(shown.state.as_str(), "closed"); assert!(!shown.evidence.approved_current_subject); assert_eq!(shown.merge_requests[0].state, "open"); } #[tokio::test] -async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_evidence() { +async fn mrless_completed_closure_is_a_ticket_judgment_not_approved_or_missing_merge_evidence() { let temp = tempfile::tempdir().unwrap(); let api = test_api_with_remote_repository(temp.path()).await; let backend = browser_ticket_backend(&api).unwrap(); @@ -682,13 +689,17 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev .unwrap(); let ticket = backend.show(reference.id.clone().into()).unwrap(); backend - .complete( + .update_case( &reference.id, - ticket::TicketCompletion { + ticket::TicketCaseUpdate { operation_key: "research-result".into(), expected_content_digest: ticket::ticket_content_digest(&ticket), - expected_state: TicketWorkflowState::Planning, - reason: "Answer recorded in the Ticket thread".into(), + expected_state: ticket::TicketState::Open, + expected_closure_operation_key: None, + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: "Answer recorded in the Ticket thread".into(), references: Vec::new(), author: Some("workspace-user".into()), }, @@ -703,7 +714,7 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev .await, ) .unwrap(); - assert_eq!(shown.state, "done"); + assert_eq!(shown.state.as_str(), "closed"); assert!(!shown.evidence.has_merge_request); assert!(!shown.evidence.approved_current_subject); assert!(!shown.evidence.complete_for_integration); @@ -728,8 +739,27 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev } } +// Exercise the generated handler's JSON/CAS boundary, then reread the real +// Ticket authority. Supply the actor normally established by middleware; signed +// case-route tests cover real authentication. No live Runtime/provider runs. +fn case_actor_router(api: WorkspaceApi) -> Router { + build_inner_router(api).layer(Extension(test_browser_request_actor())) +} + +fn case_request(before: &ticket::Ticket, operation_key: &str, action: Value) -> Value { + json!({ + "operation_key": operation_key, + "expected_content_digest": ticket::ticket_content_digest(before), + "expected_state": before.meta.workflow_state, + "expected_closure_operation_key": before.closure_operation_key, + "action": action, + "explanation": "Explicit user case judgment", + "references": [{"kind": "ticket", "target": "T-42"}], + }) +} + #[tokio::test] -async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_receipts() { +async fn case_route_rejects_missing_cas_client_author_and_removed_workflow_routes() { let temp = tempfile::tempdir().unwrap(); let api = test_api_with_remote_repository(temp.path()).await; let backend = browser_ticket_backend(&api).unwrap(); @@ -737,85 +767,328 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re .create(ticket::NewTicket::new("Transport decisions")) .unwrap(); let before = backend.show(reference.id.clone().into()).unwrap(); - let app = build_inner_router(api); - let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id); - for suffix in ["workflow-state", "state-changes", "state-fields/state"] { + let app = case_actor_router(api); + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let request = case_request( + &before, + "close", + json!({"action": "close", "reason": "completed"}), + ); + for field in [ + "operation_key", + "expected_content_digest", + "expected_state", + "action", + "explanation", + ] { + let mut missing = request.clone(); + missing.as_object_mut().unwrap().remove(field); request_json( app.clone(), "POST", - &format!("{path}/{suffix}"), - Some(json!({ - "from":"planning","to":"done","reason":"old unguarded request","body":"" - })), + &path, + Some(missing), StatusCode::UNPROCESSABLE_ENTITY, ) .await; } + for (field, value) in [ + ("author", json!("spoofed-client-author")), + ("expected_state", json!("done")), + ("action", json!({"action": "close", "reason": "invented"})), + ] { + let mut invalid = request.clone(); + invalid[field] = value; + request_json( + app.clone(), + "POST", + &path, + Some(invalid), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + } + let legacy = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id); + for suffix in [ + "workflow-state", + "state-changes", + "state-fields/state", + "workflow/close", + "complete", + ] { + // A surviving GET catch-all can produce 405 rather than 404 for an + // absent POST route. Neither status authorizes a legacy mutation. + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("{legacy}/{suffix}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(request.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert!( + matches!( + response.status(), + StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED + ), + "legacy POST {suffix}: {}", + response.status() + ); + } + request_json( + app, + "POST", + &format!( + "/workspaces/019d0000-0000-7000-8000-0000000000bb/tickets/{}/case", + reference.id + ), + Some(request), + StatusCode::NOT_FOUND, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), before); +} + +#[tokio::test] +async fn case_route_preserves_corrected_history_replay_and_reopen_aba_guard_without_mr() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api_with_remote_repository(temp.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new( + "Case judgments without repository work", + )) + .unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + let digest = ticket::ticket_content_digest(&before); + let app = case_actor_router(api); + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let detail_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id); + let close = case_request( + &before, + "guarded-close", + json!({"action": "close", "reason": "completed"}), + ); + let response = request_json( + app.clone(), + "POST", + &path, + Some(close.clone()), + StatusCode::OK, + ) + .await; + let summary: server_api::TicketSummary = serde_json::from_value(response).unwrap(); + assert_eq!(summary.0.workflow_state, ticket::TicketState::Closed); + let closed = backend.show(reference.id.clone().into()).unwrap(); + let actor = test_browser_request_actor(); + let expected_author = format!( + "workspace-user:{}", + serde_json::to_string(&(&actor.account_id, &actor.user_id)).unwrap() + ); + assert_eq!( + closed.current_closure.as_ref().unwrap().author, + expected_author + ); + let event = closed.events.last().unwrap(); + assert_eq!(event.attributes["source_account_id"], actor.account_id); + assert_eq!(event.attributes["source_user_id"], actor.user_id); + assert_eq!( + closed.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Completed + ); + assert_eq!(closed.case_history.len(), 1); + assert_eq!( + closed.case_history[0].references, + closed.current_closure.as_ref().unwrap().references + ); + + let correction = case_request( + &closed, + "correct-closure", + json!({"action": "correct_closure", "reason": {"other": "Answer superseded by user decision"}}), + ); request_json( app.clone(), "POST", - &format!("{path}/workflow/close"), - Some(json!("unguarded close")), - StatusCode::UNPROCESSABLE_ENTITY, + &path, + Some(correction.clone()), + StatusCode::OK, ) .await; - let close = json!({ - "operation_key":"guarded-close","expected_content_digest":ticket::ticket_content_digest(&before), - "expected_state":"planning","reason":"No further work required","author":"spoofed-client-author" - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("{path}/workflow/close")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(close.to_string())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); - let closed = backend.show(reference.id.clone().into()).unwrap(); - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - let receipt = closed.events.last().unwrap(); - assert_eq!(receipt.author.as_deref(), Some("workspace-user")); - assert_eq!(receipt.from.as_deref(), Some("planning")); + let corrected = backend.show(reference.id.clone().into()).unwrap(); + assert_eq!(corrected.case_history.len(), 2); + assert_eq!(corrected.case_history[0], closed.case_history[0]); assert_eq!( - receipt.attributes.get("operation_key"), - Some(&"guarded-close".to_string()) + corrected.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Other("Answer superseded by user decision".into()) ); - let reopen = json!({ - "operation_key":"reopen","expected_content_digest":ticket::ticket_content_digest(&closed), - "expected_state":"closed","state":"planning","reason":"A new question arrived" - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("{path}/workflow-state")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(reopen.to_string())) - .unwrap(), - ) - .await + assert_eq!( + corrected.closure_operation_key.as_deref(), + Some("correct-closure") + ); + let detail: TicketDetail = + serde_json::from_value(get_json(app.clone(), &detail_path).await).unwrap(); + assert_eq!(detail.current_closure, corrected.current_closure); + assert_eq!(detail.closure_history, corrected.case_history); + assert_eq!( + detail.closure_operation_key, + corrected.closure_operation_key + ); + assert!(detail.legacy_handoff.is_none()); + assert!(detail.merge_requests.is_empty()); + + // Same state and content are insufficient after correcting a closure. + let stale_correction = case_request( + &closed, + "stale-correction", + json!({"action": "correct_closure", "reason": "duplicate"}), + ); + request_json( + app.clone(), + "POST", + &path, + Some(stale_correction), + StatusCode::CONFLICT, + ) + .await; + let reopen = case_request(&corrected, "reopen", json!({"action": "reopen"})); + request_json( + app.clone(), + "POST", + &path, + Some(reopen.clone()), + StatusCode::OK, + ) + .await; + let reopened = backend.show(reference.id.clone().into()).unwrap(); + assert_eq!(reopened.meta.workflow_state, ticket::TicketState::Open); + assert!(reopened.current_closure.is_none()); + assert_eq!( + reopened.closure_operation_key, + corrected.closure_operation_key + ); + assert_eq!(reopened.case_history.len(), 3); + assert_eq!(ticket::ticket_content_digest(&reopened), digest); + + // Exact replay returns current authority, not the old closed response. + for replay in [close.clone(), correction, reopen] { + let result = request_json(app.clone(), "POST", &path, Some(replay), StatusCode::OK).await; + let summary: server_api::TicketSummary = serde_json::from_value(result).unwrap(); + assert_eq!(summary.0.workflow_state, ticket::TicketState::Open); + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), reopened); + } + let mut changed_replay = close.clone(); + changed_replay["explanation"] = json!("Different judgment under the same operation key"); + request_json( + app.clone(), + "POST", + &path, + Some(changed_replay), + StatusCode::CONFLICT, + ) + .await; + let mut aba_close = close; + aba_close["operation_key"] = json!("stale-open-close"); + request_json( + app.clone(), + "POST", + &path, + Some(aba_close), + StatusCode::CONFLICT, + ) + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), reopened); + let second_close = case_request( + &reopened, + "second-close", + json!({"action": "close", "reason": "not_planned"}), + ); + request_json( + app.clone(), + "POST", + &path, + Some(second_close), + StatusCode::OK, + ) + .await; + let final_ticket = backend.show(reference.id.into()).unwrap(); + assert_eq!( + final_ticket.meta.workflow_state, + ticket::TicketState::Closed + ); + assert_eq!(final_ticket.case_history.len(), 4); + assert_eq!(final_ticket.case_history[..3], reopened.case_history); + assert_eq!( + final_ticket.closure_operation_key.as_deref(), + Some("second-close") + ); + assert_eq!(ticket::ticket_content_digest(&final_ticket), digest); +} + +#[tokio::test] +async fn case_route_rejects_stale_content_state_and_invalid_actions_without_audit_side_effects() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api_with_remote_repository(temp.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("CAS decision")) .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); - // Exact replay of the old close is a receipt lookup, not another close. - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("{path}/workflow/close")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(close.to_string())) - .unwrap(), + let before = backend.show(reference.id.clone().into()).unwrap(); + let app = case_actor_router(api); + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let close = case_request( + &before, + "close", + json!({"action": "close", "reason": "unknown"}), + ); + for (field, value) in [ + ("expected_content_digest", json!("stale-content")), + ("expected_state", json!("closed")), + ("action", json!({"action": "reopen"})), + ( + "action", + json!({"action": "correct_closure", "reason": "duplicate"}), + ), + ] { + let mut invalid = close.clone(); + invalid[field] = value; + request_json( + app.clone(), + "POST", + &path, + Some(invalid), + StatusCode::CONFLICT, ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); - let reopened = backend.show(reference.id.into()).unwrap(); - assert_eq!(reopened.meta.workflow_state, TicketWorkflowState::Planning); - assert_eq!(reopened.events.len(), closed.events.len() + 1); + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + } + request_json(app.clone(), "POST", &path, Some(close), StatusCode::OK).await; + let closed = backend.show(reference.id.clone().into()).unwrap(); + let duplicate_close = case_request( + &closed, + "duplicate-close", + json!({"action": "close", "reason": "completed"}), + ); + request_json( + app, + "POST", + &path, + Some(duplicate_close), + StatusCode::CONFLICT, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), closed); } diff --git a/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs index 3e8a47a1b..1e55f7564 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs @@ -42,17 +42,7 @@ async fn explicit_existing_worker_claim_accepts_no_resources_and_preserves_assig }, ) .unwrap(); - backend - .set_workflow_state( - fixture.ticket_id.clone().into(), - TicketStateChange::new( - "ready", - "planning", - "Explicit investigation", - "No repository resources required", - ), - ) - .unwrap(); + let before = backend.show(fixture.ticket_id.clone().into()).unwrap(); let request = manual_worker_assignment_request(&fixture, "claim-without-resources"); assert!(request.workdir_bindings.is_empty()); let Json(first) = claim(&fixture, request.clone()).await.unwrap(); @@ -67,8 +57,16 @@ async fn explicit_existing_worker_claim_accepts_no_resources_and_preserves_assig .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - "inprogress" + .state + .as_str(), + "open" + ); + let after = backend.show(fixture.ticket_id.clone().into()).unwrap(); + assert_eq!(after.meta.workflow_state, before.meta.workflow_state); + assert_eq!(after.case_history, before.case_history); + assert_eq!( + ticket::ticket_content_digest(&after), + ticket::ticket_content_digest(&before) ); assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1); assert!( @@ -282,8 +280,9 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - "ready" + .state + .as_str(), + "open" ); assert!( fixture @@ -313,8 +312,9 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - "inprogress" + .state + .as_str(), + "open" ); assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 3); assert_eq!( @@ -435,8 +435,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits .is_empty() ); let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Manual investigation without resources"); - input.workflow_state = Some(TicketWorkflowState::Ready); + let input = ticket::NewTicket::new("Manual investigation without resources"); let ticket_id = backend.create(input).unwrap().id; backend .edit_item( @@ -447,19 +446,9 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits }, ) .unwrap(); - backend - .set_workflow_state( - ticket_id.clone().into(), - TicketStateChange::new( - "ready", - "planning", - "Explicit investigation", - "No repository resources required", - ), - ) - .unwrap(); let admitted_content_digest = api.authority.ticket(&ticket_id).unwrap().content_digest; let request = server_api::SetTicketRoleAssignmentRequest { + orchestrator_assignment_id: None, operation_id: "claim-runtime-reconciliation".into(), principal: server_api::TicketAssignmentPrincipal::Worker { runtime_id: worker.runtime_id.clone(), @@ -503,7 +492,10 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits assert!(pending.assignment_id.is_none()); assert_eq!(pending.worker, Some(worker.clone())); assert!(pending.request_fingerprint.is_some()); - assert_eq!(api.authority.ticket(&ticket_id).unwrap().state, "planning"); + assert_eq!( + api.authority.ticket(&ticket_id).unwrap().state.as_str(), + "open" + ); assert!( api.store .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id) @@ -601,8 +593,8 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits ); assert_eq!(committed.request_fingerprint, pending.request_fingerprint); assert_eq!( - api.authority.ticket(&ticket_id).unwrap().state, - "inprogress" + api.authority.ticket(&ticket_id).unwrap().state.as_str(), + "open" ); assert_eq!( api.store diff --git a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs index f9082a218..733df3809 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs @@ -144,7 +144,7 @@ impl LaunchFixture { async fn new(remote: bool) -> Self { let temp = tempfile::tempdir().unwrap(); let config = test_server_config(temp.path()); - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); let backend = Arc::new(LaunchExecutionBackend::default()); let api = WorkspaceApi::new_with_execution_backend(config, Arc::new(store), backend.clone()) @@ -267,17 +267,36 @@ impl LaunchFixture { fixture } - fn ticket(&self, state: TicketWorkflowState) -> String { - let mut input = - ticket::NewTicket::new(format!("Explicit Worker launch from {}", state.as_str())); - input.workflow_state = Some(state); + fn ticket(&self) -> String { browser_ticket_backend(&self.api) .unwrap() - .create(input) + .create(ticket::NewTicket::new( + "Explicit Worker launch on an open case", + )) .unwrap() .id } + fn case_action(&self, ticket_id: &str, operation_key: &str, action: ticket::TicketCaseAction) { + let backend = browser_ticket_backend(&self.api).unwrap(); + let before = backend.show(ticket_id.into()).unwrap(); + backend + .update_case( + ticket_id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&before), + expected_state: before.meta.workflow_state, + expected_closure_operation_key: before.closure_operation_key, + operation_key: operation_key.into(), + action, + explanation: "Explicit fixture case judgment".into(), + references: Vec::new(), + author: Some("fixture".into()), + }, + ) + .unwrap(); + } + fn payload(&self, ticket_id: &str, operation_id: &str, profile: &str, flow: bool) -> Value { self.backend.publish_profile(&self.api, profile); let mut segments = vec![ @@ -361,24 +380,40 @@ impl LaunchFixture { Some(current.assignment_id.as_str()) ); assert_eq!( - self.api.authority.ticket(ticket_id).unwrap().state, - "inprogress" + self.api.authority.ticket(ticket_id).unwrap().state.as_str(), + "open" ); current } } #[tokio::test(flavor = "multi_thread")] -async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_or_orchestrator() { +async fn embedded_explicit_worker_starts_open_cases_without_phase_transitions_or_queue_requirement() +{ let fixture = LaunchFixture::new(false).await; - for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - ] { - let ticket_id = fixture.ticket(state); - let operation_id = format!("explicit-{}", state.as_str()); + for context in ["unqueued", "queued", "reopened"] { + let ticket_id = fixture.ticket(); + let backend = browser_ticket_backend(&fixture.api).unwrap(); + if context == "queued" { + backend + .queue_ready(ticket_id.clone().into(), "fixture") + .unwrap(); + } else if context == "reopened" { + fixture.case_action( + &ticket_id, + "fixture-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + fixture.case_action( + &ticket_id, + "fixture-reopen", + ticket::TicketCaseAction::Reopen, + ); + } + let before = backend.show(ticket_id.clone().into()).unwrap(); + let operation_id = format!("explicit-{context}"); assert!( fixture .api @@ -393,8 +428,16 @@ async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_ ); let payload = fixture.payload(&ticket_id, &operation_id, "builtin:companion", false); let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; - assert_eq!(status, StatusCode::OK, "{}: {body}", state.as_str()); + assert_eq!(status, StatusCode::OK, "{context}: {body}"); let assignment = fixture.assert_assigned(&ticket_id, &operation_id, &body); + let after = backend.show(ticket_id.clone().into()).unwrap(); + assert_eq!(after.meta.workflow_state, before.meta.workflow_state); + assert_eq!(after.case_history, before.case_history); + assert_eq!(after.closure_operation_key, before.closure_operation_key); + assert_eq!( + ticket::ticket_content_digest(&after), + ticket::ticket_content_digest(&before) + ); assert_eq!(body["worker"]["profile"], "builtin:companion", "{body}"); let creates = fixture.backend.creates_for(&assignment.worker.worker_id); assert_eq!(creates.len(), 1); @@ -413,7 +456,7 @@ async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_ async fn real_runtime_http_worker_launch_preserves_optional_flow_profile_submit_and_assignment() { let fixture = LaunchFixture::new(true).await; for (profile, flow) in [("builtin:intake", false), ("builtin:coder", true)] { - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let operation_id = format!("real-http-{flow}"); let payload = fixture.payload(&ticket_id, &operation_id, profile, flow); let expected_submit = payload["initial_submit"].clone(); @@ -489,7 +532,7 @@ async fn worker_launch_uses_published_project_profile_and_workspace_default_on_r .commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate) .unwrap(); for explicit in [true, false] { - let ticket_id = fixture.ticket(TicketWorkflowState::Ready); + let ticket_id = fixture.ticket(); let operation_id = format!("project-profile-{explicit}"); let mut payload = fixture.payload(&ticket_id, &operation_id, "project:research", false); if !explicit { @@ -511,21 +554,35 @@ async fn worker_launch_uses_published_project_profile_and_workspace_default_on_r } #[tokio::test(flavor = "multi_thread")] -async fn completed_or_closed_ticket_launch_rejects_before_runtime_or_assignment_reservation() { +async fn closed_ticket_launch_rejects_before_runtime_or_assignment_reservation() { let fixture = LaunchFixture::new(false).await; - for state in [TicketWorkflowState::Done, TicketWorkflowState::Closed] { - let ticket_id = fixture.ticket(state); - let operation_id = format!("terminal-{}", state.as_str()); + for (label, reason) in [ + ("completed", ticket::TicketClosureReason::Completed), + ("not-planned", ticket::TicketClosureReason::NotPlanned), + ] { + let ticket_id = fixture.ticket(); + fixture.case_action( + &ticket_id, + "fixture-close", + ticket::TicketCaseAction::Close { reason }, + ); + let operation_id = format!("terminal-{label}"); let payload = fixture.payload(&ticket_id, &operation_id, "builtin:companion", false); let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; assert_eq!(status, StatusCode::CONFLICT, "{body}"); assert!( - body["message"].as_str().unwrap().contains(state.as_str()), + body["message"].as_str().unwrap().contains("closed"), "{body}" ); assert_eq!( - fixture.api.authority.ticket(&ticket_id).unwrap().state, - state.as_str() + fixture + .api + .authority + .ticket(&ticket_id) + .unwrap() + .state + .as_str(), + "closed" ); assert!( fixture @@ -551,7 +608,7 @@ async fn completed_or_closed_ticket_launch_rejects_before_runtime_or_assignment_ #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_exact_launch_replays_keep_one_worker_and_committed_assignment() { let fixture = Arc::new(LaunchFixture::new(false).await); - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let payload = fixture.payload(&ticket_id, "concurrent-launch", "builtin:companion", false); let barrier = Arc::new(tokio::sync::Barrier::new(5)); let mut tasks = Vec::new(); @@ -596,7 +653,7 @@ async fn concurrent_exact_launch_replays_keep_one_worker_and_committed_assignmen #[tokio::test(flavor = "multi_thread")] async fn undispatched_launch_rejection_settles_claim_and_allows_corrected_operation() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let mut rejected = fixture.payload(&ticket_id, "invalid-resources", "builtin:companion", false); rejected["workdir_attachments"] = json!([{"alias":"checkout","working_directory_id":"missing-workdir"}]); @@ -629,7 +686,7 @@ async fn undispatched_launch_rejection_settles_claim_and_allows_corrected_operat #[tokio::test(flavor = "multi_thread")] async fn worker_launch_retry_reuses_assignment_and_does_not_repeat_initial_submit() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Ready); + let ticket_id = fixture.ticket(); let payload = fixture.payload(&ticket_id, "worker-retry", "builtin:companion", false); let (status, first) = fixture.post(TEST_WORKSPACE_ID, payload.clone()).await; assert_eq!(status, StatusCode::OK, "{first}"); @@ -682,7 +739,7 @@ async fn worker_launch_retry_reuses_assignment_and_does_not_repeat_initial_submi async fn generic_ticket_worker_unknown_input_outcome_preserves_assignment_and_launch_retry_identity() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let payload = fixture.payload( &ticket_id, "worker-unknown-input", @@ -745,7 +802,7 @@ async fn generic_ticket_worker_unknown_input_outcome_preserves_assignment_and_la #[tokio::test(flavor = "multi_thread")] async fn scoped_worker_launch_rejects_foreign_workspace_and_unpublished_profile_with_body() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let payload = fixture.payload(&ticket_id, "scope-rejected", "builtin:companion", false); let (status, body) = fixture .post("019d0000-0000-7000-8000-0000000000bb", payload.clone()) @@ -904,10 +961,10 @@ async fn read_only_ticket_targets_allow_extra_external_grants_without_widening_c } #[tokio::test(flavor = "multi_thread")] -async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_and_preserves_planning() +async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_and_preserves_open_case() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); rusqlite::Connection::open(&fixture.api.config.database_path) .unwrap() .execute_batch( @@ -966,8 +1023,14 @@ async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_an .is_none() ); assert_eq!( - fixture.api.authority.ticket(&ticket_id).unwrap().state, - "planning" + fixture + .api + .authority + .ticket(&ticket_id) + .unwrap() + .state + .as_str(), + "open" ); let counts: (i64, i64) = fixture.api.config_store.with_conn(|conn| Ok(( conn.query_row("SELECT COUNT(*) FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", rusqlite::params![TEST_WORKSPACE_ID, fixture.runtime_id, worker_id], |row| row.get(0))?, @@ -979,7 +1042,7 @@ async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_an #[tokio::test(flavor = "multi_thread")] async fn choosing_reviewer_profile_does_not_register_a_reviewer_child_or_grant_mr_review() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let payload = fixture.payload(&ticket_id, "ordinary-reviewer", "builtin:reviewer", false); let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; assert_eq!(status, StatusCode::OK, "{body}"); @@ -1215,7 +1278,140 @@ async fn uncertain_launch_replay_after_target_edit_keeps_claim_and_blocks_compet .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - "ready" + .state + .as_str(), + "open" + ); +} + +// Direct policy composition is intentional: this protects routing interest, +// independently of the transport/Runtime launch assertions above. +#[tokio::test(flavor = "multi_thread")] +async fn assignment_inputs_replace_queue_interest_without_reviving_released_work() { + let fixture = LaunchFixture::new(false).await; + let backend = browser_ticket_backend(&fixture.api).unwrap(); + let queued_id = fixture.ticket(); + assign_test_orchestrator(&fixture.api, &queued_id); + assert!( + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) + .unwrap() + .iter() + .any(|input| input.ticket_id == queued_id) + == true + ); + backend + .queue_ready(queued_id.clone().into(), "fixture") + .unwrap(); + assert!( + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) + .unwrap() + .iter() + .any(|input| input.ticket_id == queued_id) + == true + ); + fixture.case_action( + &queued_id, + "queue-case-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + assert!( + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) + .unwrap() + .iter() + .any(|input| input.ticket_id == queued_id) + == false + ); + fixture.case_action( + &queued_id, + "queue-case-reopen", + ticket::TicketCaseAction::Reopen, + ); + assert!( + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) + .unwrap() + .iter() + .any(|input| input.ticket_id == queued_id) + == false + ); + + let active_id = fixture.ticket(); + assign_test_orchestrator(&fixture.api, &active_id); + let payload = fixture.payload( + &active_id, + "routing-active-work", + "builtin:companion", + false, + ); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; + assert_eq!(status, StatusCode::OK, "{body}"); + fixture.assert_assigned(&active_id, "routing-active-work", &body); + let active = backend.show(active_id.clone().into()).unwrap(); + assert!( + active + .events + .iter() + .all(|event| !event.attributes.contains_key("queue_root_ticket")) + ); + assert!( + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) + .unwrap() + .iter() + .any(|input| input.ticket_id == active_id) + == true + ); + fixture.case_action( + &active_id, + "active-case-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + fixture.case_action( + &active_id, + "active-case-reopen", + ticket::TicketCaseAction::Reopen, + ); + assert!( + fixture + .api + .store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &active_id) + .unwrap() + .is_some() + ); + assert!( + fixture + .api + .store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &active_id) + .unwrap() + .is_none() + ); + assert!( + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) + .unwrap() + .iter() + .any(|input| input.ticket_id == active_id) + == false ); } diff --git a/crates/workspace-server/src/server/tests/worker_drive_tests.rs b/crates/workspace-server/src/server/tests/worker_drive_tests.rs index 2b24e45a5..481c309ad 100644 --- a/crates/workspace-server/src/server/tests/worker_drive_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_drive_tests.rs @@ -151,7 +151,7 @@ impl Fixture { let temp = tempfile::tempdir().unwrap(); let mut config = test_server_config(temp.path()); config.repositories.clear(); - let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); let mut api = WorkspaceApi::new_with_execution_backend( config, store, diff --git a/crates/workspace-server/src/server/tests/worker_operations_tests.rs b/crates/workspace-server/src/server/tests/worker_operations_tests.rs index 80bfe89bc..574c48b8e 100644 --- a/crates/workspace-server/src/server/tests/worker_operations_tests.rs +++ b/crates/workspace-server/src/server/tests/worker_operations_tests.rs @@ -632,12 +632,14 @@ async fn request_context_uses_verified_subject_not_headers_or_account_fallback() assert!(WorkerOperationContext::from_request(&api, &headers_only).is_err()); let mut runtime_only = operation_request_context(Some(server_api::ServerRuntimeSource { runtime_id: "runtime-a".into(), + authentication_binding_id: "fixture-binding".into(), worker_id: None, })); runtime_only.actor = Some(test_browser_request_actor()); assert!(WorkerOperationContext::from_request(&api, &runtime_only).is_err()); let mut mismatched = operation_request_context(Some(server_api::ServerRuntimeSource { runtime_id: "runtime-a".into(), + authentication_binding_id: "fixture-binding".into(), worker_id: Some("controller-a".into()), })); mismatched.actor = Some(test_browser_request_actor()); @@ -646,6 +648,7 @@ async fn request_context_uses_verified_subject_not_headers_or_account_fallback() seed_worker_source_member(&api, "runtime-a", "controller-a"); let context = operation_request_context(Some(server_api::ServerRuntimeSource { runtime_id: "runtime-a".into(), + authentication_binding_id: "fixture-binding".into(), worker_id: Some("controller-a".into()), })); assert!( @@ -665,6 +668,7 @@ async fn generic_management_aliases_cannot_bypass_worker_grants_or_target_bound_ let context = || { operation_request_context(Some(server_api::ServerRuntimeSource { runtime_id: "offline-runtime".into(), + authentication_binding_id: "fixture-binding".into(), worker_id: Some("controller".into()), })) }; @@ -826,7 +830,11 @@ async fn grant_replacement_while_admission_waits_cannot_use_another_grants_lock( }; let admission = handle.set_pinned(&context, true); tokio::pin!(admission); - // Poll deterministically to the held lock before replacing grant authority. + // The first poll can now be waiting for the offloaded authority read, not + // the held grant lock. Drain this fixture's actual SQL, then poll its saved + // reply to reach admission before replacing authority. + assert!(futures::poll!(admission.as_mut()).is_pending()); + api.store.db_executor().drain().await.unwrap(); assert!(futures::poll!(admission.as_mut()).is_pending()); api.store .revoke_worker_control_grant( @@ -1325,8 +1333,9 @@ async fn restore_guard_internal_stale_conflict_retires_intent_for_later_explicit ); assert_eq!(api.runtime.worker(&identity).unwrap().state, "stopped"); // Retirement, unlike in-memory refresh, must survive caller journal restart. - api.config_store = - Arc::new(SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap()); + api.config_store = Arc::new( + SqliteWorkspaceStore::open_for_test(api.config.database_path.clone()).unwrap(), + ); let fresh = api.runtime.worker(&identity).unwrap(); let next = api .config_store @@ -1486,7 +1495,7 @@ async fn restore_guard_internal_intent_survives_client_store_restart_without_rep ); // Reopen the durable caller journal, discarding all client-side memory of request identity. api.config_store = - Arc::new(SqliteWorkspaceStore::open(api.config.database_path.clone()).unwrap()); + Arc::new(SqliteWorkspaceStore::open_for_test(api.config.database_path.clone()).unwrap()); let pinned = api .config_store .pin_internal_worker_restore_intent( @@ -1595,7 +1604,7 @@ async fn restore_guard_cross_runtime_ssh_completion_retries_pinned_snapshot_at_w crate::repository_source::repository_source_fingerprint(&source); config.repositories[0].source = source; config.repositories[0].path = None; - let store = SqliteWorkspaceStore::open(config.database_path.clone()).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(config.database_path.clone()).unwrap(); let execution = Arc::new(DeterministicExecutionBackend::default()); execution.accept_restores(); let api = WorkspaceApi::new_with_execution_backend(config, Arc::new(store), execution.clone()) diff --git a/crates/workspace-server/src/server/tests/worker_protocol_tests.rs b/crates/workspace-server/src/server/tests/worker_protocol_tests.rs new file mode 100644 index 000000000..c88f3dee9 --- /dev/null +++ b/crates/workspace-server/src/server/tests/worker_protocol_tests.rs @@ -0,0 +1,210 @@ +use super::*; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::sync::oneshot; + +// The real bridge owns this sink exactly as it owns a TCP WebSocket sink. It +// accepts bytes but never finishes flushing: delivery is deliberately unknown. +struct StalledWire { + entered: Option>, + dropped: Option>, + sent: std::sync::Arc>>, +} +impl futures::Sink for StalledWire { + type Error = tokio_tungstenite::tungstenite::Error; + fn poll_ready( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn start_send( + mut self: Pin<&mut Self>, + message: TungsteniteMessage, + ) -> std::result::Result<(), Self::Error> { + self.sent.lock().unwrap().push(message); + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); + } + Ok(()) + } + fn poll_flush( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } + fn poll_close( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } +} +impl Drop for StalledWire { + fn drop(&mut self) { + if let Some(dropped) = self.dropped.take() { + let _ = dropped.send(()); + } + } +} +pub(super) fn stalled_remote_bridge() -> ( + WorkspaceWorkerProtocolConnection, + oneshot::Receiver<()>, + oneshot::Receiver<()>, + std::sync::Arc>>, +) { + let (entered_tx, entered) = oneshot::channel(); + let (dropped_tx, dropped) = oneshot::channel(); + let sent = std::sync::Arc::new(std::sync::Mutex::new(Vec::new())); + let sink = StalledWire { + entered: Some(entered_tx), + dropped: Some(dropped_tx), + sent: sent.clone(), + }; + let connection = remote_worker_protocol_bridge(sink, futures::stream::pending()); + (connection, entered, dropped, sent) +} + +#[tokio::test(start_paused = true)] +async fn remote_pending_method_timeout_closes_both_queues_and_never_retries_unknown_delivery() { + let (mut connection, entered, dropped, sent) = stalled_remote_bridge(); + connection + .methods + .try_send(protocol_test_completion("delivery-unknown")) + .unwrap(); + entered.await.unwrap(); + for _ in 0..WORKER_PROTOCOL_METHOD_CAPACITY { + connection + .methods + .try_send(protocol::Method::ListPendingSubmissions) + .unwrap(); + } + // Queues are saturated while the actual bridge is stalled in a wire send. + assert!( + connection + .methods + .try_send(protocol::Method::ListWorkers) + .is_err() + ); + tokio::time::advance(std::time::Duration::from_secs(10)).await; + assert_eq!( + connection.termination.ended().await, + WorkerProtocolEnd::TransportTimeout + ); + dropped.await.unwrap(); + assert!(connection.methods.is_closed()); + assert!(connection.events.recv().await.is_none()); + assert_eq!( + sent.lock().unwrap().len(), + 1, + "must not retry or drain uncertain pending methods" + ); +} + +#[tokio::test] +async fn remote_disconnect_and_last_capability_drop_reclaim_a_pending_wire_send() { + for explicit in [false, true] { + let (connection, entered, dropped, sent) = stalled_remote_bridge(); + connection + .methods + .try_send(protocol_test_completion("cancelled-wire")) + .unwrap(); + entered.await.unwrap(); + if explicit { + connection + .termination + .terminate(WorkerProtocolEnd::Disconnected); + } + drop(connection); + tokio::time::timeout(std::time::Duration::from_secs(2), dropped) + .await + .unwrap() + .unwrap(); + assert_eq!(sent.lock().unwrap().len(), 1); + } +} + +#[tokio::test] +async fn remote_actual_connector_overflow_ends_connection_and_preserves_delivered_event_order() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let (release_tx, release) = oneshot::channel(); + let (wire_closed_tx, wire_closed) = oneshot::channel(); + let peer = tokio::spawn(async move { + let (stream, _) = listener.accept().await.unwrap(); + let mut socket = tokio_tungstenite::accept_hdr_async( + stream, + |request: &tokio_tungstenite::tungstenite::handshake::server::Request, response| { + assert_eq!( + request.headers()["authorization"], + "Bearer test-worker-protocol" + ); + assert_eq!( + request.headers()[protocol::AUTHENTICATED_ACCOUNT_ID_HEADER], + "test-account" + ); + Ok(response) + }, + ) + .await + .unwrap(); + release.await.unwrap(); + for i in 0..=WORKER_PROTOCOL_EVENT_CAPACITY { + let event = protocol::Event::TextDelta { + text: i.to_string(), + }; + let text = protocol::stream::encode_event(&event).unwrap(); + // TCP may observe closure before all writes flush; either way the + // consumer must reach the same explicit bounded-overflow end. + if socket + .send(TungsteniteMessage::Text(text.into())) + .await + .is_err() + { + break; + } + } + while let Some(Ok(message)) = socket.next().await { + if matches!(message, TungsteniteMessage::Close(_)) { + break; + } + } + let _ = wire_closed_tx.send(()); + }); + let config = RuntimeObservationSourceConfig { + worker: RuntimeWorkerRef::new("test-runtime", "test-worker"), + endpoint: format!("ws://{addr}/protocol/ws"), + bearer_token: Some("test-worker-protocol".into()), + }; + let mut connection = connect_remote_worker_protocol( + config, + Some(&protocol::AuthenticatedInputSource::Account { + account_id: "test-account".into(), + }), + ) + .await + .unwrap(); + release_tx.send(()).unwrap(); + assert_eq!( + tokio::time::timeout( + std::time::Duration::from_secs(2), + connection.termination.ended() + ) + .await + .unwrap(), + WorkerProtocolEnd::EventOverflow + ); + for i in 0..WORKER_PROTOCOL_EVENT_CAPACITY { + assert!( + matches!(connection.events.recv().await, Some(protocol::Event::TextDelta { text }) if text == i.to_string()) + ); + } + assert!(connection.events.recv().await.is_none()); + assert!(connection.methods.is_closed()); + tokio::time::timeout(std::time::Duration::from_secs(2), wire_closed) + .await + .unwrap() + .unwrap(); + peer.await.unwrap(); +} diff --git a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs index 9bfbd3364..142ae143e 100644 --- a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs +++ b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs @@ -56,7 +56,7 @@ impl Fixture { api.runtime.register_or_replace(runtime); let now = Arc::new(AtomicI64::new(START)); let clock = now.clone(); - let mut server = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + let mut server = WorkspaceServerApi::new_for_test(api.config.clone(), api.store.clone()); server.proof_clock = Arc::new(move || clock.load(Ordering::SeqCst)); server .apis @@ -330,9 +330,38 @@ async fn same_worker_session_stays_exclusive_but_proof_is_verified_before_waitin let f = Fixture::new().await; let mut a = f.attach("1"); let (first, reply) = f.start_read("1", &mut a).await; - let mut second = Box::pin(f.app.clone().oneshot(f.operation("1"))); - // Poll through dispatch/auth until the handler waits on its resource lock. - assert!(futures::poll!(&mut second).is_pending()); + let request = f.operation("1"); + let proof = request + .headers() + .get(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER) + .unwrap() + .to_str() + .unwrap(); + let jti = worker_runtime::auth::decode_runtime_request_source_claims(proof) + .unwrap() + .jti; + let mut second = Box::pin(f.app.clone().oneshot(request)); + // DB auth is now genuinely asynchronous. Observe durable proof consumption, + // not a guessed number of polls, before advancing the injected proof clock. + let connection = rusqlite::Connection::open(&f.api.config.database_path).unwrap(); + tokio::time::timeout(BOUND, async { + loop { + assert!(futures::poll!(&mut second).is_pending()); + let consumed: bool = connection + .query_row( + "SELECT EXISTS(SELECT 1 FROM worker_mutation_source_proof_jtis WHERE jti=?1)", + [&jti], + |row| row.get(0), + ) + .unwrap(); + if consumed { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("second request never authenticated before resource wait"); assert!( a.try_recv().is_err(), "same session read escaped resource exclusion" @@ -342,7 +371,12 @@ async fn same_worker_session_stays_exclusive_but_proof_is_verified_before_waitin assert_json(first.await.unwrap(), StatusCode::OK).await; assert!(futures::poll!(&mut second).is_pending()); let Some(ExternalProviderCommand::Operation { response, .. }) = - tokio::time::timeout(BOUND, a.recv()).await.unwrap() + tokio::time::timeout(BOUND, async { + tokio::select! { + command = a.recv() => command, + result = second.as_mut() => panic!("second read ended before provider dispatch: {:?}", result.unwrap().status()), + } + }).await.unwrap() else { panic!("expected second read") }; @@ -848,12 +882,18 @@ async fn create_and_restore_session_callbacks_finish_while_deletion_start_waits( }), ); let mut deletion = Box::pin(f.app.clone().oneshot(request)); - assert!(futures::poll!(&mut deletion).is_pending()); let gate = f.server.admission_for_workspace(TEST_WORKSPACE_ID).await; - assert!( - gate.admit(false).is_none(), - "deletion start did not fence admission" - ); + tokio::time::timeout(BOUND, async { + loop { + assert!(futures::poll!(&mut deletion).is_pending()); + if gate.admit(false).is_none() { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("deletion start did not fence admission after async authentication"); let session_id = session_store::new_session_id().to_string(); let callback_path = format!("/api/w/{TEST_WORKSPACE_ID}/subjektiv/sessions"); let callback = f.request( diff --git a/crates/workspace-server/src/server/tests/workspace_protocol_backpressure_tests.rs b/crates/workspace-server/src/server/tests/workspace_protocol_backpressure_tests.rs new file mode 100644 index 000000000..add77016e --- /dev/null +++ b/crates/workspace-server/src/server/tests/workspace_protocol_backpressure_tests.rs @@ -0,0 +1,140 @@ +use super::*; +use protocol::subscription::{ + EventSubscriptionSelector, SubscriptionFrame, SubscriptionFramePayload, SubscriptionRequest, + SubscriptionRequestId, +}; +use std::pin::Pin; +use std::task::{Context, Poll}; +use tokio::sync::{mpsc, oneshot}; + +// A controlled browser wire. Receiving remains independent of a blocked writer, +// reproducing the real multiplexer split without filling an OS socket by timing. +struct CongestedBrowser { + inbound: mpsc::Receiver, + entered: Option>, + dropped: Option>, +} +impl futures::Stream for CongestedBrowser { + type Item = std::result::Result; + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + self.inbound.poll_recv(cx).map(|message| message.map(Ok)) + } +} +impl futures::Sink for CongestedBrowser { + type Error = std::convert::Infallible; + fn poll_ready( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll> { + Poll::Ready(Ok(())) + } + fn start_send(mut self: Pin<&mut Self>, _: WsMessage) -> std::result::Result<(), Self::Error> { + if let Some(entered) = self.entered.take() { + let _ = entered.send(()); + } + Ok(()) + } + fn poll_flush( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } + fn poll_close( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll> { + Poll::Pending + } +} +impl Drop for CongestedBrowser { + fn drop(&mut self) { + if let Some(dropped) = self.dropped.take() { + let _ = dropped.send(()); + } + } +} + +#[tokio::test(start_paused = true)] +async fn actual_workspace_multiplexer_stalled_writer_timeout_or_disconnect_reclaims_connection_tasks() + { + for exit in ["timeout", "disconnect", "caller-cancel"] { + let workspace = tempfile::tempdir().unwrap(); + let (api, _) = test_api_with_recording_backend(workspace.path()).await; + let identity = spawn_ticket_check_source(&api, "multiplexer-stalled-writer"); + let (inbound, receiver) = mpsc::channel(300); + let (entered_tx, entered) = oneshot::channel(); + let (dropped_tx, dropped) = oneshot::channel(); + let socket = CongestedBrowser { + inbound: receiver, + entered: Some(entered_tx), + dropped: Some(dropped_tx), + }; + let frame = SubscriptionFrame::new(SubscriptionFramePayload::Request( + SubscriptionRequest::SubscribeEvents { + request_id: SubscriptionRequestId::new("saturated-browser").unwrap(), + selector: EventSubscriptionSelector::WorkspaceWorkers, + }, + )); + inbound + .try_send(WsMessage::Text( + serde_json::to_string(&frame).unwrap().into(), + )) + .unwrap(); + let task = tokio::spawn(crate::workspace_subscription::serve_workspace_subscription( + api.clone(), + socket, + WorkerOperationContext::Backend, + )); + entered.await.unwrap(); // Actual subscription snapshot reached the blocked wire. + match exit { + "timeout" => { + // Fill the control queue as well; the receiving side may now be + // waiting for Pong capacity. Writer failure must preempt that wait. + for _ in 0..258 { + inbound + .try_send(WsMessage::Ping(Vec::new().into())) + .unwrap(); + } + tokio::time::advance(std::time::Duration::from_secs(10)).await; + task.await.unwrap(); + } + "disconnect" => { + drop(inbound); + task.await.unwrap(); + } + "caller-cancel" => { + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + } + _ => unreachable!(), + } + dropped.await.unwrap(); + let worker = + WorkspaceWorker::resolve(&api, &identity.runtime_id, &identity.worker_id).unwrap(); + let mut fresh = worker + .connect_protocol(&WorkerOperationContext::Backend) + .await + .unwrap(); + assert!(matches!( + fresh.events.recv().await, + Some(protocol::Event::Snapshot { .. }) + )); + let stopped = worker + .stop( + &WorkerOperationContext::Backend, + WorkerLifecycleRequest { + reason: Some("cleanup-after-multiplexer-exit".into()), + ticket_assignment: None, + }, + ) + .await + .unwrap(); + assert_eq!(stopped.state, InternalWorkerOperationState::Accepted); + assert!( + current_worker_session_lock(&api, &identity) + .try_lock() + .is_ok() + ); + } +} diff --git a/crates/workspace-server/src/server/worker_operations.rs b/crates/workspace-server/src/server/worker_operations.rs index 157fd840e..d9d3ff289 100644 --- a/crates/workspace-server/src/server/worker_operations.rs +++ b/crates/workspace-server/src/server/worker_operations.rs @@ -88,6 +88,19 @@ impl WorkerOperationContext { } Ok(()) } + pub(super) async fn from_request_async( + api: &WorkspaceApi, + context: &server_api::ServerRequestContext, + ) -> std::result::Result { + let owned_api = api.clone(); + let context = context.clone(); + api.store + .db_executor() + .run(move || Ok::<_, Error>(Self::from_request(&owned_api, &context))) + .await + .map_err(|error| ApiError::from(error).into_repository_api_error())? + } + pub(super) fn from_request( api: &WorkspaceApi, context: &server_api::ServerRequestContext, @@ -193,8 +206,20 @@ impl WorkspaceWorker { source: crate::worker_source::VerifiedWorkerMutationSource, reason: &str, ) -> std::result::Result { - self.current_record() - .map_err(|error| error.error.to_string())?; + let removal = self.removal.clone(); + let identity = self.identity.clone(); + self.removal + .store + .db_executor() + .run(move || { + removal + .store + .get_worker_registry(&removal.workspace_id, &identity)? + .ok_or_else(|| Error::UnknownWorker { worker: identity }) + .map(drop) + }) + .await + .map_err(|error| error.to_string())?; self.removal .execute_async( source, @@ -256,6 +281,20 @@ impl crate::worker_source::VerifiedWorkerRemoveExecutor for EmbeddedWorkspaceWor } impl WorkspaceWorker { + pub(crate) async fn resolve_async( + api: &WorkspaceApi, + runtime_id: &str, + reference: &str, + ) -> Result { + let owned_api = api.clone(); + let runtime_id = runtime_id.to_owned(); + let reference = reference.to_owned(); + api.store + .db_executor() + .run(move || Self::resolve(&owned_api, &runtime_id, &reference)) + .await + } + pub(crate) fn resolve(api: &WorkspaceApi, runtime_id: &str, reference: &str) -> Result { Self::resolve_with_services( api.clone(), @@ -270,7 +309,36 @@ impl WorkspaceWorker { context: &WorkerOperationContext, permission: &str, ) -> ApiResult>> { - self.current_record()?; + let worker = self.clone(); + let initial_context = context.clone(); + let permission = permission.to_owned(); + let initial_permission = permission.clone(); + let grant = self + .api + .store + .db_executor() + .run(move || -> ApiResult> { + worker.current_record()?; + let WorkerOperationContext::WorkerControl { controller } = initial_context else { + return Ok(None); + }; + if worker + .api + .store + .get_worker_registry(worker.api.workspace_id(), &controller)? + .is_none() + { + return Err(Error::UnknownWorker { worker: controller }.into()); + } + Ok(Some(authorize_known_worker_permission( + &worker.api, + worker.api.workspace_id(), + &controller, + &worker.identity, + &initial_permission, + )?)) + }) + .await?; match context { #[cfg(test)] WorkerOperationContext::Backend => Ok(None), @@ -279,42 +347,35 @@ impl WorkspaceWorker { Ok(None) } WorkerOperationContext::WorkerControl { controller } => { - if self - .api - .store - .get_worker_registry(self.api.workspace_id(), controller)? - .is_none() - { - return Err(Error::UnknownWorker { - worker: controller.clone(), - } - .into()); - } - let grant = authorize_known_worker_permission( - &self.api, - self.api.workspace_id(), - controller, - &self.identity, - permission, - )?; + let grant = grant.expect("Worker control context resolved a grant"); + // Await admission outside the DB lane; the recheck owns the guard + // until it finishes, even if its async waiter is cancelled. let guard = worker_control_lock(&self.api, &grant.grant_id) .lock_owned() .await; - self.current_record()?; - let current_grant = authorize_known_worker_permission( - &self.api, - self.api.workspace_id(), - controller, - &self.identity, - permission, - )?; - if current_grant.grant_id != grant.grant_id { - return Err(Error::RepositoryConflict( - "Worker control grant changed while waiting for admission".into(), - ) - .into()); - } - Ok(Some(guard)) + let worker = self.clone(); + let controller = controller.clone(); + self.api + .store + .db_executor() + .run(move || -> ApiResult<_> { + worker.current_record()?; + let current_grant = authorize_known_worker_permission( + &worker.api, + worker.api.workspace_id(), + &controller, + &worker.identity, + &permission, + )?; + if current_grant.grant_id != grant.grant_id { + return Err(Error::RepositoryConflict( + "Worker control grant changed while waiting for admission".into(), + ) + .into()); + } + Ok(Some(guard)) + }) + .await } } } @@ -324,8 +385,16 @@ impl WorkspaceWorker { context: &WorkerOperationContext, request: server_api::WorkerRestoreRequest, ) -> ApiResult { - let _guard = self.authorize_operation(context, "restore").await?; - self.restore_internal(request) + let guard = self.authorize_operation(context, "restore").await?; + let worker = self.clone(); + self.api + .host_executors + .host()? + .run(move || { + let _guard = guard; + worker.restore_internal(request) + }) + .await } /// Trusted Backend callers still supply an explicit observed intent. Runtime @@ -343,27 +412,40 @@ impl WorkspaceWorker { context: &WorkerOperationContext, request: WorkerLifecycleRequest, ) -> ApiResult { - let _guard = self.authorize_operation(context, "stop").await?; - let result = self - .api - .runtime - .stop_worker(&self.identity, request) - .map_err(|e| e.into_error())?; - if result.state != InternalWorkerOperationState::Accepted { - return Ok(result); - } - parse_runtime_worker_id_for_registry(&self.identity.worker_id)?; - let session_lock = current_worker_session_lock(&self.api, &self.identity); - let _session_guard = session_lock.lock().await; - close_current_worker_session_locked(&self.api, &self.identity).await?; - if let Some(record) = self - .api - .store - .get_worker_registry(self.api.workspace_id(), &self.identity)? - { - sync_linked_workdir_after_worker_stop(&self.api, &self.identity.runtime_id, &record)?; - } - Ok(result) + let guard = self.authorize_operation(context, "stop").await?; + let worker = self.clone(); + // Once admitted, Runtime stop, session close and linked Workdir + // publication have one owner. Never hand an accepted stop's guards + // back to its cancellable waiter or acquire this Host lane again. + run_host_workflow(self.api.host_executors.host()?, async move { + let _control = guard; + let result = worker + .api + .runtime + .stop_worker(&worker.identity, request) + .map_err(|error| error.into_error())?; + if result.state != InternalWorkerOperationState::Accepted { + return Ok(result); + } + parse_runtime_worker_id_for_registry(&worker.identity.worker_id)?; + let _session = current_worker_session_lock(&worker.api, &worker.identity) + .lock_owned() + .await; + close_current_worker_session_locked(&worker.api, &worker.identity).await?; + if let Some(record) = worker + .api + .store + .get_worker_registry(worker.api.workspace_id(), &worker.identity)? + { + sync_linked_workdir_after_worker_stop( + &worker.api, + &worker.identity.runtime_id, + &record, + )?; + } + Ok(result) + }) + .await } pub(super) async fn cancel( @@ -371,12 +453,20 @@ impl WorkspaceWorker { context: &WorkerOperationContext, request: WorkerLifecycleRequest, ) -> ApiResult { - let _guard = self.authorize_operation(context, "cancel").await?; - Ok(self - .api - .runtime - .cancel_worker(&self.identity, request) - .map_err(|e| e.into_error())?) + let guard = self.authorize_operation(context, "cancel").await?; + let worker = self.clone(); + self.api + .host_executors + .host()? + .run(move || -> ApiResult<_> { + let _guard = guard; + Ok(worker + .api + .runtime + .cancel_worker(&worker.identity, request) + .map_err(|e| e.into_error())?) + }) + .await } pub(super) async fn input( @@ -389,46 +479,61 @@ impl WorkspaceWorker { } else { "send_input" }; - let _guard = self.authorize_operation(context, permission).await?; + let guard = self.authorize_operation(context, permission).await?; let session_lock = current_worker_session_lock(&self.api, &self.identity); - let _session_guard = session_lock.lock().await; - self.ensure_interactive()?; + let session_guard = session_lock.lock_owned().await; + self.ensure_interactive().await?; if matches!(context, WorkerOperationContext::WorkerControl { .. }) && request.kind == WorkerInputKind::User { request.kind = WorkerInputKind::UserIfIdle; } - Ok(self - .api - .runtime - .send_input(&self.identity, request) - .map_err(|e| e.into_error())?) + let worker = self.clone(); + self.api + .host_executors + .host()? + .run(move || -> ApiResult<_> { + let (_control, _session) = (guard, session_guard); + Ok(worker + .api + .runtime + .send_input(&worker.identity, request) + .map_err(|e| e.into_error())?) + }) + .await } - fn ensure_interactive(&self) -> ApiResult<()> { - if let Some(claim) = self - .api + async fn ensure_interactive(&self) -> ApiResult<()> { + let worker = self.clone(); + self.api .store - .get_pending_ticket_assignment_operation_for_worker( - self.api.workspace_id(), - &self.identity, - )? - { - return Err(Error::TicketAssignmentConflict(format!( - "Worker has pending Ticket claim {}; reconcile that exact operation before admitting input", - claim.operation_id, - )).into()); - } - if self - .api - .store - .worker_registry_projection(self.api.workspace_id(), &self.identity)? - .and_then(|projection| projection.job) - .is_some() - { - return Err(Error::InvalidInput("Backend Job Workers accept only their Backend-owned immutable input; Console is read-only".into()).into()); - } - Ok(()) + .db_executor() + .run(move || -> ApiResult<()> { + if let Some(claim) = worker + .api + .store + .get_pending_ticket_assignment_operation_for_worker( + worker.api.workspace_id(), + &worker.identity, + )? + { + return Err(Error::TicketAssignmentConflict(format!( + "Worker has pending Ticket claim {}; reconcile that exact operation before admitting input", + claim.operation_id, + )).into()); + } + if worker + .api + .store + .worker_registry_projection(worker.api.workspace_id(), &worker.identity)? + .and_then(|projection| projection.job) + .is_some() + { + return Err(Error::InvalidInput("Backend Job Workers accept only their Backend-owned immutable input; Console is read-only".into()).into()); + } + Ok(()) + }) + .await } pub(super) async fn set_pinned( @@ -436,33 +541,44 @@ impl WorkspaceWorker { context: &WorkerOperationContext, pinned: bool, ) -> ApiResult { - let _guard = self.authorize_operation(context, "pin").await?; - let retention_state = if pinned { "pinned" } else { "normal" }; - let changed = self.api.worker_projection.publish_ordered(|| { - self.api - .store - .update_worker_retention( - self.api.workspace_id(), - &self.identity, - retention_state, - now_registry_timestamp().as_str(), - ) - .map(|commit| (!commit.changes.is_empty(), commit)) - })?; - if !changed { - return Err(cleanup_api_error( - &self.identity.runtime_id, - "workspace_worker_retention_unknown_worker", - "Worker is not known to the Backend registry", - )); - } - Ok(WorkerRetentionResponse { - workspace_id: self.api.workspace_id().to_owned(), - runtime_id: self.identity.runtime_id.clone(), - worker_id: self.identity.worker_id.clone(), - pinned, - retention_state: retention_state.into(), - }) + let guard = self.authorize_operation(context, "pin").await?; + let worker = self.clone(); + self.api + .store + .db_executor() + .run(move || -> ApiResult { + // Publication and its DB mutation are one owned stage. Accepted + // work retains control admission through commit and publication. + let _guard = guard; + let retention_state = if pinned { "pinned" } else { "normal" }; + let changed = worker.api.worker_projection.publish_ordered(|| { + worker + .api + .store + .update_worker_retention( + worker.api.workspace_id(), + &worker.identity, + retention_state, + now_registry_timestamp().as_str(), + ) + .map(|commit| (!commit.changes.is_empty(), commit)) + })?; + if !changed { + return Err(cleanup_api_error( + &worker.identity.runtime_id, + "workspace_worker_retention_unknown_worker", + "Worker is not known to the Backend registry", + )); + } + Ok(WorkerRetentionResponse { + workspace_id: worker.api.workspace_id().to_owned(), + runtime_id: worker.identity.runtime_id.clone(), + worker_id: worker.identity.worker_id.clone(), + pinned, + retention_state: retention_state.into(), + }) + }) + .await } /// The caller owns the full cleanup-plan digest check. The removal service @@ -501,10 +617,11 @@ impl WorkspaceWorker { let connection = connect_workspace_worker_protocol(&self.api, &self.identity, source).await?; Ok(WorkspaceWorkerConnection { - sender: WorkspaceWorkerMethodSender { - worker: self.clone(), - methods: connection.methods, - }, + sender: WorkspaceWorkerMethodSender::new( + self.clone(), + connection.methods, + connection.termination, + ), events: connection.events, }) } @@ -516,12 +633,27 @@ impl WorkspaceWorker { pub(crate) struct WorkspaceWorkerMethodSender { worker: WorkspaceWorker, methods: tokio::sync::mpsc::Sender, + pub(super) termination: WorkerProtocolTermination, } pub(crate) struct WorkspaceWorkerConnection { pub(crate) sender: WorkspaceWorkerMethodSender, pub(crate) events: tokio::sync::mpsc::Receiver, } impl WorkspaceWorkerMethodSender { + pub(crate) fn end_status(&self) -> tokio::sync::watch::Receiver> { + self.termination.subscribe() + } + pub(super) fn new( + worker: WorkspaceWorker, + methods: tokio::sync::mpsc::Sender, + termination: WorkerProtocolTermination, + ) -> Self { + Self { + worker, + methods, + termination, + } + } pub(crate) async fn send( &self, context: &WorkerOperationContext, @@ -626,7 +758,7 @@ impl WorkspaceWorkerMethodSender { .await?; let session_lock = current_worker_session_lock(&self.worker.api, &self.worker.identity); let _session_guard = session_lock.lock().await; - self.worker.ensure_interactive()?; + self.worker.ensure_interactive().await?; let WorkerOperationContext::Browser { source, .. } = context else { return Err(Error::WorkspacePermissionDenied( "Browser Worker protocol context required".into(), @@ -635,15 +767,25 @@ impl WorkspaceWorkerMethodSender { }; let method = authorize_browser_worker_method(method, source) .map_err(|message| Error::InvalidInput(message.into()))?; - self.methods.send(method).await.map_err(|_| { + // Session exclusion fences attach/stop only through admission, never delivery. + let result = if self.termination.reason().is_some() { + Err(tokio::sync::mpsc::error::TrySendError::Closed(method)) + } else { + self.methods.try_send(method) + }; + result.map_err(|error| { + let (code, message) = match error { + tokio::sync::mpsc::error::TrySendError::Full(_) => { + self.termination.terminate(WorkerProtocolEnd::MethodOverflow); + ("worker_protocol_overflow", "Worker method queue overflow; this method was not admitted. Earlier pending methods may have unknown delivery; reconnect with a fresh snapshot, no automatic retry") + } + tokio::sync::mpsc::error::TrySendError::Closed(_) => + ("worker_protocol_closed", "Worker connection closed; pending delivery may be unknown; no implicit Restore or retry was performed"), + }; Error::RuntimeOperationFailed { runtime_id: self.worker.identity.runtime_id.clone(), - code: "worker_protocol_closed".into(), - message: - "Worker execution connection closed; no implicit Restore or retry was performed" - .into(), - } - .into() + code: code.into(), message: message.into(), + }.into() }) } } diff --git a/crates/workspace-server/src/server/worker_protocol.rs b/crates/workspace-server/src/server/worker_protocol.rs new file mode 100644 index 000000000..63b4bb573 --- /dev/null +++ b/crates/workspace-server/src/server/worker_protocol.rs @@ -0,0 +1,265 @@ +//! Bounded operational protocol connections, separate from Workspace request admission. +//! +//! Each bridge captures one execution/transport, never a restored endpoint under the same +//! Worker ID. Snapshot precedes live events; direct replies and events keep bridge ordering, +//! without another pump. Method admission never waits for queue capacity under a session +//! guard. Event enqueue never waits; overflow ends the stream rather than skipping deltas. +//! The first terminal cause wins and preempts pending authorization and transport waits. +//! +//! Full-queue rejection means that method was not queued. Earlier queued methods, interrupted +//! sends and begun execution can have unknown side effects: no automatic retry or synthesized +//! success/non-execution. Cancel uses the same bounded queue, not an unbounded control lane. +//! The last client capability owns abort-on-drop; the pump does not retain that owner. +//! Event-consumer closure cancels the pump too. Reclamation drops channels and transport, +//! not the Worker, and is not rollback of a delivered method. +//! +//! Handshake and wire writes, including browser Pong/error, have bounded deadlines. The +//! dedicated session makes one separately bounded 1011 Close attempt; a blocked wire may +//! receive no terminal frame. Reconnect/resubscribe explicitly and replace local state with +//! a fresh snapshot; never replay old queues/deltas or implicitly Restore/retarget execution. +//! A begun synchronous embedded call settles its cancellation-and-join adapter wait before +//! endpoint exclusion is released; disconnect cannot immediately preempt that call. +use super::*; +use futures::{Sink, Stream}; +use std::future::Future; +use tokio::sync::{mpsc, watch}; + +pub(super) const WORKER_PROTOCOL_METHOD_CAPACITY: usize = 256; +pub(super) const WORKER_PROTOCOL_EVENT_CAPACITY: usize = 512; +pub(super) const WORKER_PROTOCOL_TRANSPORT_TIMEOUT: std::time::Duration = + std::time::Duration::from_secs(10); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub(crate) enum WorkerProtocolEnd { + MethodOverflow, + EventOverflow, + TransportTimeout, + Disconnected, +} +impl WorkerProtocolEnd { + fn message(self) -> &'static str { + match self { + Self::MethodOverflow => "Worker protocol method queue overflow", + Self::EventOverflow => "Worker protocol event queue overflow/lag", + Self::TransportTimeout => "Worker protocol transport timed out", + Self::Disconnected => "Worker protocol disconnected", + } + } +} + +struct WorkerProtocolPump(tokio::task::AbortHandle); +impl Drop for WorkerProtocolPump { + fn drop(&mut self) { + self.0.abort(); + } +} + +/// The last client capability owns the pump. The pump holds only the watch sender, +/// not this owner, so dropping a cancelled connection cannot leave an orphan task. +#[derive(Clone)] +pub(crate) struct WorkerProtocolTermination { + state: watch::Sender>, + _pump: std::sync::Arc, +} +impl WorkerProtocolTermination { + pub(crate) fn subscribe(&self) -> watch::Receiver> { + self.state.subscribe() + } + pub(crate) fn reason(&self) -> Option { + *self.state.borrow() + } + pub(crate) fn terminate(&self, reason: WorkerProtocolEnd) { + finish(&self.state, reason); + } + pub(crate) async fn ended(&self) -> WorkerProtocolEnd { + ended(&mut self.state.subscribe()).await + } +} +fn finish(state: &watch::Sender>, reason: WorkerProtocolEnd) { + state.send_if_modified(|current| { + if current.is_some() { + return false; + } + *current = Some(reason); + true + }); +} +async fn ended(state: &mut watch::Receiver>) -> WorkerProtocolEnd { + loop { + if let Some(reason) = *state.borrow_and_update() { + return reason; + } + if state.changed().await.is_err() { + return WorkerProtocolEnd::Disconnected; + } + } +} + +pub(crate) struct WorkspaceWorkerProtocolConnection { + pub(crate) methods: mpsc::Sender, + pub(crate) events: mpsc::Receiver, + pub(crate) termination: WorkerProtocolTermination, +} + +pub(super) fn spawn_worker_protocol_pump(pump: F) -> WorkspaceWorkerProtocolConnection +where + F: FnOnce(mpsc::Receiver, mpsc::Sender) -> Fut, + Fut: Future + Send + 'static, +{ + let (methods, receiver) = mpsc::channel(WORKER_PROTOCOL_METHOD_CAPACITY); + let (sender, events) = mpsc::channel(WORKER_PROTOCOL_EVENT_CAPACITY); + let (state, mut shutdown) = watch::channel(None); + let task_state = state.clone(); + let closed = sender.clone(); + let future = pump(receiver, sender); + let task = tokio::spawn(async move { + let reason = tokio::select! { + biased; + reason = ended(&mut shutdown) => reason, + _ = closed.closed() => WorkerProtocolEnd::Disconnected, + reason = future => reason, + }; + finish(&task_state, reason); + // Dropping the future releases both queues and the captured execution/WS endpoint. + }); + WorkspaceWorkerProtocolConnection { + methods, + events, + termination: WorkerProtocolTermination { + state, + _pump: std::sync::Arc::new(WorkerProtocolPump(task.abort_handle())), + }, + } +} + +pub(super) fn enqueue_worker_protocol_event( + sender: &mpsc::Sender, + event: protocol::Event, +) -> std::result::Result<(), WorkerProtocolEnd> { + sender.try_send(event).map_err(|error| match error { + mpsc::error::TrySendError::Full(_) => WorkerProtocolEnd::EventOverflow, + mpsc::error::TrySendError::Closed(_) => WorkerProtocolEnd::Disconnected, + }) +} + +/// Actual remote bridge pump, also usable with a bounded in-process wire fixture. +/// A transport send may have partially succeeded when interrupted: no retry or success +/// acknowledgement is synthesized. All transport waits have a deadline; cancellation +/// of the common owner drops the pending send and both halves of this one connection. +pub(super) fn remote_worker_protocol_bridge( + mut sink: S, + mut stream: R, +) -> WorkspaceWorkerProtocolConnection +where + S: Sink + Unpin + Send + 'static, + R: Stream< + Item = std::result::Result, + > + Unpin + + Send + + 'static, + S::Error: Send, +{ + spawn_worker_protocol_pump(move |mut methods, events| async move { + loop { + tokio::select! { + method = methods.recv() => { + let Some(method) = method else { return WorkerProtocolEnd::Disconnected; }; + let Ok(text) = protocol::stream::encode_method(&method) else { return WorkerProtocolEnd::Disconnected; }; + match tokio::time::timeout(WORKER_PROTOCOL_TRANSPORT_TIMEOUT, sink.send(TungsteniteMessage::Text(text.into()))).await { + Ok(Ok(())) => {}, + Ok(Err(_)) => return WorkerProtocolEnd::Disconnected, + Err(_) => return WorkerProtocolEnd::TransportTimeout, + } + }, + message = stream.next() => match message { + Some(Ok(TungsteniteMessage::Text(text))) => { + let Ok(event) = protocol::stream::decode_event(text.as_ref()) else { return WorkerProtocolEnd::Disconnected; }; + if let Err(reason) = enqueue_worker_protocol_event(&events, event) { return reason; } + } + Some(Ok(TungsteniteMessage::Ping(value))) => { + match tokio::time::timeout(WORKER_PROTOCOL_TRANSPORT_TIMEOUT, sink.send(TungsteniteMessage::Pong(value))).await { + Ok(Ok(())) => {}, + Ok(Err(_)) => return WorkerProtocolEnd::Disconnected, + Err(_) => return WorkerProtocolEnd::TransportTimeout, + } + } + Some(Ok(TungsteniteMessage::Pong(_))) => {}, + _ => return WorkerProtocolEnd::Disconnected, + } + } + } + }) +} + +/// Browser admission can await authorization/session ownership, but never method queue +/// capacity. Common termination preempts that wait. Browser writes are bounded too. +pub(super) async fn workspace_worker_protocol_session( + mut connection: worker_operations::WorkspaceWorkerConnection, + mut socket: S, + context: WorkerOperationContext, +) where + S: Stream> + Sink + Unpin, +{ + let termination = connection.sender.termination.clone(); + let session = async { + loop { + tokio::select! { + inbound = socket.next() => match inbound { + Some(Ok(WsMessage::Text(text))) => { + let result = match decode_method(&text) { + Ok(method) => connection.sender.send(&context, method).await.map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }; + if let Err(error) = result { + let event = protocol_error_event(error); + let Ok(text) = protocol::stream::encode_event(&event) else { return WorkerProtocolEnd::Disconnected; }; + if let Err(reason) = browser_send(&mut socket, WsMessage::Text(text.into())).await { return reason; } + } + } + Some(Ok(WsMessage::Ping(value))) => { + if let Err(reason) = browser_send(&mut socket, WsMessage::Pong(value)).await { return reason; } + } + Some(Ok(WsMessage::Pong(_))) => {}, + _ => return WorkerProtocolEnd::Disconnected, + }, + event = connection.events.recv() => { + let Some(event) = event else { return WorkerProtocolEnd::Disconnected; }; + let shutdown = matches!(event, protocol::Event::Shutdown); + let Ok(text) = protocol::stream::encode_event(&event) else { return WorkerProtocolEnd::Disconnected; }; + if let Err(reason) = browser_send(&mut socket, WsMessage::Text(text.into())).await { return reason; } + if shutdown { return WorkerProtocolEnd::Disconnected; } + } + } + } + }; + let reason = tokio::select! { + biased; + reason = termination.ended() => reason, + reason = session => reason, + }; + termination.terminate(reason); + // Best-effort explicit close even if the downstream transport is also blocked. + // The close reason deliberately makes no claim that admitted methods did not run. + let _ = tokio::time::timeout( + WORKER_PROTOCOL_TRANSPORT_TIMEOUT, + socket.send(WsMessage::Close(Some(axum::extract::ws::CloseFrame { + code: 1011, + reason: format!( + "{}; pending delivery unknown; resync snapshot; no retry", + reason.message() + ) + .into(), + }))), + ) + .await; +} +async fn browser_send + Unpin>( + socket: &mut S, + message: WsMessage, +) -> std::result::Result<(), WorkerProtocolEnd> { + match tokio::time::timeout(WORKER_PROTOCOL_TRANSPORT_TIMEOUT, socket.send(message)).await { + Ok(Ok(())) => Ok(()), + Ok(Err(_)) => Err(WorkerProtocolEnd::Disconnected), + Err(_) => Err(WorkerProtocolEnd::TransportTimeout), + } +} diff --git a/crates/workspace-server/src/server/workspace_admission.rs b/crates/workspace-server/src/server/workspace_admission.rs index 1769c8224..4c13c0fe6 100644 --- a/crates/workspace-server/src/server/workspace_admission.rs +++ b/crates/workspace-server/src/server/workspace_admission.rs @@ -13,7 +13,10 @@ //! //! Drop releases leases and temporary deletion fences. Cancelling a deletion wait reopens normal //! admission, but durable Workspace state blocks admission after deletion reservation succeeds. -//! Leases cover HTTP responses, not the full lifetime of upgraded WS or background work. +//! Accepted bounded jobs retain the admitted request lease through execution and result cleanup, +//! even after the HTTP waiter is cancelled. Synchronous child submissions retain that source too; +//! detached tasks do not inherit it. Leases do not cover an upgraded WS's entire lifetime or all +//! background work. Only explicitly allowed callback routes may reenter deletion drain. use std::sync::{Arc, Mutex}; use tokio::sync::{Mutex as AsyncMutex, Notify, OwnedMutexGuard}; @@ -80,6 +83,28 @@ pub(super) struct RequestLease { callback: bool, } +impl crate::db_executor::DbOperationLifetime for RequestLease { + fn retain(&self) -> Box { + // This is continuation of an already admitted request, not fresh admission. + // Retain its drain responsibility before transferring work to the DB owner; + // dropping/aborting the HTTP waiter must not allow deletion past live SQL. + let mut state = self + .gate + .state + .lock() + .expect("Workspace admission poisoned"); + if self.callback { + state.callbacks += 1; + } else { + state.requests += 1; + } + Box::new(RequestLease { + gate: self.gate.clone(), + callback: self.callback, + }) + } +} + impl Drop for RequestLease { fn drop(&mut self) { let mut state = self diff --git a/crates/workspace-server/src/server_assignment_attention_tests.rs b/crates/workspace-server/src/server_assignment_attention_tests.rs new file mode 100644 index 000000000..ee20767bc --- /dev/null +++ b/crates/workspace-server/src/server_assignment_attention_tests.rs @@ -0,0 +1,1094 @@ +async fn attention_test_api() -> ( + tempfile::TempDir, + WorkspaceApi, + Arc, +) { + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let (api, execution) = test_api_with_recording_backend(dir.path()).await; + attention_assign_ticket(&api, "Attention backlog"); + (dir, api, execution) +} + +fn attention_assign_ticket(api: &WorkspaceApi, title: &str) -> ticket::TicketRef { + let mut input = ticket::NewTicket::new(title); + input.workflow_state = Some(TicketState::Open); + set_test_ticket_target(&mut input, "test-repository", "HEAD"); + let ticket = browser_ticket_backend(api).unwrap().create(input).unwrap(); + assign_test_orchestrator(api, &ticket.id); + ticket +} + +fn attention_pending(api: &WorkspaceApi) -> Vec { + api.store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) + .unwrap() +} + +async fn start_attention_test_orchestrator( + api: &WorkspaceApi, +) -> BrowserWorkspaceOrchestratorResponse { + scoped_start_workspace_orchestrator( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + ) + .await + .unwrap() + .0 +} + +#[tokio::test] +async fn orchestrator_attention_parallel_start_and_idle_observation_send_once() { + let (_dir, api, execution) = attention_test_api().await; + let reentrant_api = api.clone(); + execution.before_input_returns(move |worker| { + use protocol::subscription::SubscriptionWorkerState as S; + // The callback is inside Runtime send_input. Neither initial Idle nor + // a real turn end may deadlock or issue another in-flight delivery. + maybe_dispatch_orchestrator_turn_end( + &reentrant_api, + &worker.worker_id.to_string(), + None, + S::Idle, + ); + maybe_dispatch_orchestrator_turn_end( + &reentrant_api, + &worker.worker_id.to_string(), + Some(S::Running), + S::Idle, + ); + }); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let results = std::thread::scope(|scope| { + let joins = (0..8) + .map(|_| { + let api = api.clone(); + let barrier = barrier.clone(); + scope.spawn(move || { + barrier.wait(); + futures::executor::block_on(start_attention_test_orchestrator(&api)) + }) + }) + .collect::>(); + joins + .into_iter() + .map(|j| j.join().unwrap()) + .collect::>() + }); + assert_eq!( + results + .iter() + .filter(|r| r.disposition == "created") + .count(), + 1 + ); + assert_eq!( + results + .iter() + .filter(|r| r.disposition == "existing") + .count(), + 7 + ); + assert_eq!(execution.take_inputs().len(), 1); + assert_eq!(execution.take_input_request_ids().len(), 1); + assert_eq!( + start_attention_test_orchestrator(&api).await.disposition, + "existing" + ); + assert!(execution.take_inputs().is_empty()); +} + +#[tokio::test] +async fn orchestrator_attention_barrier_callers_share_unsent_reservation() { + let (_dir, api, execution) = attention_test_api().await; + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let state = orchestrator_attention_state(&api, &worker); + state.lock().unwrap().accepted = None; + attention_assign_ticket(&api, "Unreceived concurrent input"); + execution.take_inputs(); + execution.take_input_request_ids(); + let barrier = Arc::new(std::sync::Barrier::new(8)); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + execution.before_input_returns(move |_| { + entered_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + }); + std::thread::scope(|scope| { + let joins = (0..8) + .map(|_| { + let api = api.clone(); + let worker = worker.clone(); + let barrier = barrier.clone(); + scope.spawn(move || { + barrier.wait(); + dispatch_orchestrator_assignment_attention(&api, &worker); + }) + }) + .collect::>(); + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + assert!(state.lock().unwrap().sending); + assert!(state.lock().unwrap().accepted.is_none()); + // The success record does not exist yet. A ninth caller must still + // return immediately rather than compare/send twice or wait on Runtime. + dispatch_orchestrator_assignment_attention(&api, &worker); + release_tx.send(()).unwrap(); + for join in joins { + join.join().unwrap(); + } + }); + assert_eq!(execution.take_inputs().len(), 1); + assert_eq!(execution.take_input_request_ids().len(), 1); + assert!(!state.lock().unwrap().sending); + assert!(state.lock().unwrap().accepted.is_some()); +} + +#[tokio::test] +async fn orchestrator_attention_input_rechecks_reserved_noop_before_and_after_comparison() { + for pause_before_comparison in [true, false] { + let (_dir, api, execution) = attention_test_api().await; + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let state = orchestrator_attention_state(&api, &worker); + let acknowledged = state.lock().unwrap().accepted.as_ref().unwrap().clone(); + assert!(attention_pending(&api).is_empty()); + execution.take_inputs(); + execution.take_input_request_ids(); + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + execution.accept_restores(); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + std::thread::scope(|scope| { + let api_for_caller = api.clone(); + let worker_for_caller = worker.clone(); + let caller = scope.spawn(move || { + let mut checks = 0; + with_orchestrator_attention_reservation( + &api_for_caller, + &worker_for_caller, + true, + |state, pending| { + if checks == 0 && pause_before_comparison { + entered_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + check_orchestrator_assignment_attention( + &api_for_caller, + &worker_for_caller, + state, + pending, + ); + if checks == 0 && !pause_before_comparison { + // Durable acknowledgement matched: check returned without sending, + // but the exact same production reservation still exists. + assert_eq!( + state.lock().unwrap().accepted.as_ref().unwrap().request_id, + acknowledged.request_id + ); + entered_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + checks += 1; + assert!(attention_pending(&api_for_caller).is_empty()); + }, + ); + assert_eq!(checks, 2, "restore must recheck the reserved no-op"); + }); + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + assert!(state.lock().unwrap().sending); + let restored = futures::executor::block_on(start_attention_test_orchestrator(&api)); + assert_eq!(restored.disposition, "restored"); + let deferred = state.lock().unwrap().input_recheck; + let old_retired = state.lock().unwrap().accepted.is_none(); + let inputs_before_release = execution.take_inputs(); + release_tx.send(()).unwrap(); + caller.join().unwrap(); + assert!(deferred); + assert!(old_retired); + assert!(inputs_before_release.is_empty()); + }); + assert!(execution.take_inputs().is_empty()); + assert!(execution.take_input_request_ids().is_empty()); + assert!(attention_pending(&api).is_empty()); + let state = state.lock().unwrap(); + assert!(state.accepted.is_none()); + assert!(!state.sending); + assert!(!state.input_recheck); + } +} + +#[tokio::test] +async fn orchestrator_attention_restore_keeps_pending_unknown_delivery_and_id() { + let (_dir, api, execution) = attention_test_api().await; + execution.reject_inputs("unknown acceptance response"); + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let state = orchestrator_attention_state(&api, &worker); + let pending = state.lock().unwrap().pending.clone().unwrap(); + execution.take_inputs(); + execution.take_input_request_ids(); + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + execution.accept_restores(); + *execution.input_failure.lock().unwrap() = None; + let restored = start_attention_test_orchestrator(&api).await; + assert_eq!(restored.disposition, "restored"); + let attempts = execution.take_inputs(); + assert_eq!(attempts.len(), 1); + assert_eq!(attempts[0].1, pending.content); + assert_eq!( + execution.take_input_request_ids(), + vec![Some(pending.request_id.clone())] + ); + assert!(state.lock().unwrap().pending.is_none()); + assert_eq!( + state.lock().unwrap().accepted.as_ref().unwrap().request_id, + pending.request_id + ); +} + +#[tokio::test] +async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() { + let (_dir, api, execution) = attention_test_api().await; + execution.reject_inputs("accepted response lost (unknown transport outcome)"); + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let state = orchestrator_attention_state(&api, &worker); + assert!(state.lock().unwrap().accepted.is_none()); + let pending = state.lock().unwrap().pending.clone().unwrap(); + let attempts = execution.take_inputs(); + let ids = execution.take_input_request_ids(); + assert_eq!(attempts.len(), 2); + assert_eq!(ids, vec![Some(pending.request_id.clone()); 2]); + // Change the backlog before retry: uncertain acceptance must never reuse + // the ID for a newly rendered payload. + attention_assign_ticket(&api, "Changed backlog"); + *execution.input_failure.lock().unwrap() = None; + assert_eq!( + start_attention_test_orchestrator(&api).await.disposition, + "existing" + ); + let retry = execution.take_inputs(); + assert_eq!( + retry.len(), + 2, + "acknowledged retry must also drain the changed backlog" + ); + assert_eq!(retry[0].1, pending.content); + assert!(retry[1].1.contains("Changed backlog")); + let ids = execution.take_input_request_ids(); + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], Some(pending.request_id)); + assert_ne!(ids[1], ids[0]); + assert!(state.lock().unwrap().pending.is_none()); + assert!(state.lock().unwrap().accepted.is_some()); + assert!(attention_pending(&api).is_empty()); + use protocol::subscription::SubscriptionWorkerState as S; + maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, Some(S::Running), S::Idle); + assert!(execution.take_inputs().is_empty()); +} + +#[tokio::test] +async fn orchestrator_attention_subscription_snapshots_only_establish_baselines() { + use crate::runtime_subscription::BrokerSubscriptionEvent as B; + use protocol::subscription::{ + SubscriptionEventPayload, SubscriptionSnapshot, SubscriptionWorker, + SubscriptionWorkerState as S, + }; + let (_dir, api, execution) = attention_test_api().await; + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let attention = orchestrator_attention_state(&api, &worker); + attention.lock().unwrap().accepted = None; + execution.take_inputs(); + attention_assign_ticket(&api, "Unreceived after reconnect"); + let summary = |state: S| -> SubscriptionWorker { + serde_json::from_value(serde_json::json!({ + "worker_id": worker.worker_id, "state": state, + })) + .unwrap() + }; + let snapshot = |state| B::Snapshot { + snapshot: SubscriptionSnapshot::Workers { + workers: vec![summary(state)], + }, + }; + let event = |state| B::Event { + payload: SubscriptionEventPayload::WorkerUpserted { + worker: summary(state), + }, + }; + let mut states = HashMap::new(); + assert!(observe_orchestrator_turn_end( + &api, + &mut states, + snapshot(S::Idle) + )); + assert!(observe_orchestrator_turn_end( + &api, + &mut states, + event(S::Idle) + )); + observe_orchestrator_turn_end(&api, &mut states, event(S::Running)); + // Neither snapshot itself nor an Idle event after reconnect is turn-end evidence. + observe_orchestrator_turn_end( + &api, + &mut states, + B::Disconnected { + message: "fixture reconnect".into(), + }, + ); + observe_orchestrator_turn_end(&api, &mut states, snapshot(S::Idle)); + observe_orchestrator_turn_end(&api, &mut states, event(S::Idle)); + observe_orchestrator_turn_end(&api, &mut states, snapshot(S::Running)); + observe_orchestrator_turn_end(&api, &mut states, snapshot(S::Idle)); + assert!(attention.lock().unwrap().accepted.is_none()); + assert!(execution.take_inputs().is_empty()); + observe_orchestrator_turn_end(&api, &mut states, event(S::Running)); + observe_orchestrator_turn_end(&api, &mut states, event(S::Idle)); + assert_eq!(execution.take_inputs().len(), 1); +} + +// Real Controller and canonical Session storage with isolated local config. +// A closed loopback model endpoint prevents any production-provider access; +// notifications commit at the model boundary before that request can fail. +struct AttentionWorkerFactory { + root: PathBuf, +} + +impl AttentionWorkerFactory { + async fn controller( + &self, + worker_id: String, + restore: bool, + ) -> std::result::Result { + let name = format!("attention-{worker_id}"); + let root = self.root.join("workers").join(&worker_id); + let store = session_store::CombinedStore::new( + session_store::WorkerSessionStore::new(root.join("session")) + .map_err(|e| e.to_string())?, + session_store::WorkerAggregateStore::new(&root, &name).map_err(|e| e.to_string())?, + ); + let manifest = manifest::WorkerManifest::from_toml(&format!( + r#" + [worker] + name = "{name}" + pwd = "./" + [model] + scheme = "anthropic" + model_id = "attention-test" + base_url = "http://127.0.0.1:1" + auth = {{ kind = "none" }} + [engine] + max_tokens = 10 + [scope] + allow = [] + "# + )) + .map_err(|e| e.to_string())?; + let context = worker::WorkerWorkspaceContext::local_filesystem(None); + let workspace_client = context.client_handle(); + let loader = worker::PromptCatalogSource::builtins_only(); + let worker = if restore { + worker::Worker::restore_from_worker_metadata_with_context( + &name, + manifest, + store, + loader, + context, + worker::WorkerFilesystemAuthority::None, + ) + .await + } else { + worker::Worker::from_manifest_with_context( + manifest, + store, + loader, + context, + worker::WorkerFilesystemAuthority::None, + ) + .await + } + .map_err(|e| e.to_string())?; + let started = worker::PreparedWorker::new( + worker.with_notification_coalesce_delay(std::time::Duration::from_millis(10)), + worker::WorkerBootstrapLayout::RuntimeManagedRun { + run_dir: root.join(uuid::Uuid::now_v7().to_string()), + bash_output_dir: root.join("bash-output"), + }, + worker::WorkerControllerTransport::InProcess, + ) + .start() + .await + .map_err(|e| e.to_string())?; + Ok(worker_runtime::worker_backend::RuntimeWorkerController { + handle: started.handle, + shutdown: Arc::new(tokio::sync::Mutex::new(Some(started.shutdown))), + controller_task: started.controller_task, + workspace_client, + }) + } +} + +#[async_trait::async_trait] +impl worker_runtime::worker_backend::RuntimeWorkerFactory for AttentionWorkerFactory { + async fn spawn_controller( + &self, + request: worker_runtime::execution::WorkerExecutionSpawnRequest, + ) -> std::result::Result { + self.controller(request.worker_ref.worker_id.to_string(), false) + .await + } + async fn restore_controller( + &self, + request: worker_runtime::execution::WorkerExecutionRestoreRequest, + ) -> std::result::Result { + self.controller(request.worker_ref.worker_id.to_string(), true) + .await + } +} + +use worker_runtime::execution as attention_execution; + +// Fault injection occurs AFTER the real Controller durably accepted input. +struct AttentionResponseLossBackend { + inner: Arc, + lose_next: std::sync::atomic::AtomicBool, + attempts: Mutex>, +} + +impl attention_execution::WorkerExecutionBackend for AttentionResponseLossBackend { + fn backend_id(&self) -> &str { + self.inner.backend_id() + } + fn spawn_worker( + &self, + r: attention_execution::WorkerExecutionSpawnRequest, + ) -> attention_execution::WorkerExecutionSpawnResult { + self.inner.spawn_worker(r) + } + fn preflight_restore( + &self, + r: &attention_execution::WorkerExecutionRestoreRequest, + ) -> std::result::Result<(), attention_execution::WorkerExecutionResult> { + self.inner.preflight_restore(r) + } + fn reconcile_restore( + &self, + r: attention_execution::WorkerExecutionRestoreRequest, + ) -> attention_execution::WorkerExecutionSpawnResult { + self.inner.reconcile_restore(r) + } + fn restore_worker( + &self, + r: attention_execution::WorkerExecutionRestoreRequest, + ) -> attention_execution::WorkerExecutionSpawnResult { + self.inner.restore_worker(r) + } + fn activate_restored_worker( + &self, + id: attention_execution::WorkerLifecycleOperationId, + worker_ref: &worker_runtime::identity::WorkerRef, + ) -> std::result::Result<(), String> { + self.inner.activate_restored_worker(id, worker_ref) + } + fn dispatch_input( + &self, + worker_ref: &worker_runtime::identity::WorkerRef, + input: worker_runtime::interaction::WorkerInput, + ) -> attention_execution::WorkerExecutionResult { + self.attempts + .lock() + .unwrap() + .push(input.submission_request_id.clone().unwrap()); + let result = self.inner.dispatch_input(worker_ref, input); + assert_eq!( + result.outcome, + attention_execution::WorkerExecutionOutcome::Accepted + ); + if self + .lose_next + .swap(false, std::sync::atomic::Ordering::SeqCst) + { + attention_execution::WorkerExecutionResult::errored( + attention_execution::WorkerExecutionOperation::Input, + "injected loss of accepted response", + ) + } else { + result + } + } + fn dispatch_method( + &self, + worker_ref: &worker_runtime::identity::WorkerRef, + method: protocol::Method, + ) -> attention_execution::WorkerExecutionResult { + self.inner.dispatch_method(worker_ref, method) + } + fn stop_worker_operation( + &self, + r: attention_execution::WorkerExecutionStopRequest, + ) -> attention_execution::WorkerExecutionResult { + self.inner.stop_worker_operation(r) + } + fn worker_snapshot( + &self, + worker_ref: &worker_runtime::identity::WorkerRef, + ) -> Option { + self.inner.worker_snapshot(worker_ref) + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn orchestrator_attention_real_worker_restore_keeps_receipt_and_new_input_commits_once() { + real_attention_worker_start_restore(false).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn orchestrator_attention_real_worker_lost_response_retry_commits_once() { + real_attention_worker_start_restore(true).await; +} + +async fn real_attention_worker_start_restore(lose_response: bool) { + use session_store::{LogEntry, Store, WorkerSessionStore}; + let dir = tempfile::tempdir().unwrap(); + init_clean_git_workspace(dir.path()); + let config = test_server_config(dir.path()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); + seed_test_registered_workspace(store.as_ref(), &config) + .await + .unwrap(); + // Real Worker Runtime adapter/controller/storage, isolated from profile + // workspace callbacks and real LLM credentials. + let execution = Arc::new( + worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new( + AttentionWorkerFactory { + root: config.embedded_runtime_store_root.clone(), + }, + ) + .unwrap(), + ); + let execution = Arc::new(AttentionResponseLossBackend { + inner: execution, + lose_next: std::sync::atomic::AtomicBool::new(lose_response), + attempts: Mutex::new(Vec::new()), + }); + let api = WorkspaceApi::new_with_execution_backend(config, store, execution.clone()) + .await + .unwrap(); + seed_test_delivery_binding(&api); + let mut input = ticket::NewTicket::new("Real notification backlog"); + input.workflow_state = Some(TicketState::Open); + set_test_ticket_target(&mut input, "test-repository", "HEAD"); + let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); + assign_test_orchestrator(&api, &ticket.id); + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let session = WorkerSessionStore::open_read_only( + api.config + .embedded_runtime_store_root + .join("workers") + .join(&worker.worker_id) + .join("session"), + ) + .unwrap(); + let entries = || { + let id = session.session_id().unwrap().unwrap(); + session + .list_segments(id) + .unwrap() + .into_iter() + .flat_map(|segment| session.read_all_read_only(id, segment).unwrap()) + .collect::>() + }; + let receipts = || { + entries() + .iter() + .rev() + .find_map(|entry| match entry { + LogEntry::Extension { + domain, payload, .. + } if domain == "worker.pending_activations.v1" => { + Some(payload["notification_receipts"].as_array().unwrap().clone()) + } + LogEntry::AnnotatedSystemItem { extensions, .. } => extensions + .iter() + .find(|e| e.domain == "worker.pending_activations.v1") + .map(|e| { + e.payload["notification_receipts"] + .as_array() + .unwrap() + .clone() + }), + _ => None, + }) + .unwrap() + }; + let commits = || { + entries().iter().filter(|entry| matches!(entry, + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(&entry.item, session_store::SystemItem::Notification { message, .. } + if message.starts_with("Assigned Tickets have unreceived input:")) + )).count() + }; + async fn wait_for_commits usize>(count: F, expected: usize) { + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let actual = count(); + assert!(actual <= expected, "duplicate canonical notifications"); + if actual == expected { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + } + }) + .await + .expect("controlled Worker notification commit"); + } + assert_eq!(receipts().len(), 1); + wait_for_commits(&commits, 1).await; + assert_eq!( + execution.attempts.lock().unwrap().len(), + if lose_response { 2 } else { 1 } + ); + let first = orchestrator_attention_state(&api, &worker) + .lock() + .unwrap() + .accepted + .clone() + .unwrap(); + let first_id = first.request_id.clone(); + assert_eq!(first.fingerprint, first.input.delivery_id); + assert!(attention_pending(&api).is_empty()); + use protocol::subscription::SubscriptionWorkerState as S; + for previous in [ + None, + Some(S::Idle), + Some(S::Stopped), + Some(S::Paused), + Some(S::Running), + ] { + maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, previous, S::Idle); + } + assert_eq!( + start_attention_test_orchestrator(&api).await.disposition, + "existing" + ); + assert_eq!(receipts().len(), 1); + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + let restored = start_attention_test_orchestrator(&api).await; + assert_eq!(restored.disposition, "restored"); + assert!( + orchestrator_attention_state(&api, &worker) + .lock() + .unwrap() + .accepted + .is_none() + ); + assert!(attention_pending(&api).is_empty()); + assert_eq!( + execution.attempts.lock().unwrap().len(), + if lose_response { 2 } else { 1 } + ); + assert_eq!(receipts().len(), 1); + assert_eq!(commits(), 1); + // Retry directly at the real Controller boundary: the same Worker's + // persisted receipt survives restore even after server memory is retired. + let retry = api + .runtime + .send_input( + &worker, + WorkerInputRequest { + kind: WorkerInputKind::Notify, + content: first.content.clone(), + submission_request_id: Some(first_id.clone()), + segments: None, + }, + ) + .unwrap(); + assert_eq!(retry.state, InternalWorkerOperationState::Accepted); + assert_eq!(receipts().len(), 1); + assert_eq!(commits(), 1); + // Only a new external input earns another delivery, not restore itself. + seed_test_api_token(api.store.as_ref(), "attention-answer"); + let observed = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .remove(0); + api.store + .answer_ticket_assignment( + TEST_WORKSPACE_ID, + &ticket.id, + &ticket::TicketAssignmentAnswer { + assignment_id: observed.assignment_id, + expected_last_event_id: observed.last_event_id.unwrap(), + operation_key: "attention-owner-answer".into(), + body: "New owner answer".into(), + }, + &crate::store::TicketOrchestratorAssignmentSourceContext::Account { + account_id: "account-attention-answer".into(), + user_id: "user-attention-answer".into(), + }, + "fixture-account", + ) + .unwrap(); + maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, Some(S::Running), S::Idle); + let second = orchestrator_attention_state(&api, &worker) + .lock() + .unwrap() + .accepted + .clone() + .unwrap(); + assert_eq!(first.input.assignment_id, second.input.assignment_id); + assert_ne!(first.input.input_event_id, second.input.input_event_id); + let second_id = second.request_id; + assert_ne!(first_id, second_id); + wait_for_commits(&commits, 2).await; + let attempts = execution.attempts.lock().unwrap(); + assert_eq!(attempts.len(), if lose_response { 4 } else { 3 }); + assert_eq!( + attempts.iter().filter(|id| **id == first_id).count(), + if lose_response { 3 } else { 2 } + ); + assert_eq!(attempts.iter().filter(|id| **id == second_id).count(), 1); + drop(attempts); + let receipts = receipts(); + assert_eq!(receipts.len(), 2); + for id in [first_id, second_id] { + assert_eq!( + receipts + .iter() + .filter(|r| r["notification_request_id"] == id) + .count(), + 1 + ); + } + // Stop only this isolated test fixture; no live dogfooding Worker is touched. + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); +} + +#[tokio::test] +async fn orchestrator_attention_restore_and_replacement_do_not_resend_acknowledged_input() { + let (_dir, api, execution) = attention_test_api().await; + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + assert_eq!(execution.take_inputs().len(), 1); + api.runtime + .stop_worker( + &worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); + execution.accept_restores(); + let restored = start_attention_test_orchestrator(&api).await; + assert_eq!(restored.disposition, "restored"); + assert_eq!(restored.worker.unwrap().worker_id, worker.worker_id); + assert!(execution.take_inputs().is_empty()); + assert!(attention_pending(&api).is_empty()); + use protocol::subscription::SubscriptionWorkerState as S; + maybe_dispatch_orchestrator_turn_end(&api, &worker.worker_id, None, S::Idle); + assert_eq!( + start_attention_test_orchestrator(&api).await.disposition, + "existing" + ); + assert!(execution.take_inputs().is_empty()); + // This is a fixture Worker, not the running dogfooding Orchestrator. + api.runtime.delete_worker(&worker).unwrap(); + api.worker_projection + .publish_ordered(|| { + api.store + .delete_worker_registry(TEST_WORKSPACE_ID, &worker) + .map(|commit| ((), commit)) + }) + .unwrap(); + let replacement = start_attention_test_orchestrator(&api).await; + let replacement_id = replacement.worker.unwrap().worker_id; + assert_ne!(replacement_id, worker.worker_id); + assert!(execution.take_inputs().is_empty()); + assert!(attention_pending(&api).is_empty()); + attention_assign_ticket(&api, "New assignment for replacement"); + let replacement = RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, replacement_id); + dispatch_orchestrator_assignment_attention(&api, &replacement); + let inputs = execution.take_inputs(); + assert_eq!(inputs.len(), 1); + assert!(inputs[0].1.contains("New assignment for replacement")); +} + +#[tokio::test] +async fn orchestrator_attention_close_reopen_does_not_revive_old_input_but_reassignment_delivers() { + for unknown_response in [false, true] { + let (_dir, api, execution) = attention_test_api().await; + if unknown_response { + execution.reject_inputs("unknown acceptance before closure"); + } + let started = start_attention_test_orchestrator(&api).await; + let worker = RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + started.worker.unwrap().worker_id, + ); + let attention = orchestrator_attention_state(&api, &worker); + let original = { + let state = attention.lock().unwrap(); + if unknown_response { + state.pending.clone().unwrap() + } else { + state.accepted.clone().unwrap() + } + }; + execution.take_inputs(); + execution.take_input_request_ids(); + *execution.input_failure.lock().unwrap() = None; + let backend = browser_ticket_backend(&api).unwrap(); + for (key, action) in [ + ( + "attention-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ), + ("attention-reopen", ticket::TicketCaseAction::Reopen), + ] { + let observed = backend + .show(original.input.ticket_id.clone().into()) + .unwrap(); + backend + .update_case( + &original.input.ticket_id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&observed), + expected_state: observed.meta.workflow_state, + expected_closure_operation_key: observed.closure_operation_key, + operation_key: key.into(), + action, + explanation: "Human case decision".into(), + references: vec![], + author: None, + }, + ) + .unwrap(); + dispatch_orchestrator_assignment_attention(&api, &worker); + assert!(attention_pending(&api).is_empty()); + assert!( + execution.take_inputs().is_empty(), + "case history must not revive old assignment input" + ); + assert!(attention.lock().unwrap().pending.is_none()); + } + // Even an external comment on the reopened case cannot revive the ended assignment. + backend + .add_event( + original.input.ticket_id.clone().into(), + ticket::NewTicketEvent::new( + ticket::TicketEventKind::Comment, + "Historical result annotation", + ), + ) + .unwrap(); + dispatch_orchestrator_assignment_attention(&api, &worker); + assert!(attention_pending(&api).is_empty()); + assert!(execution.take_inputs().is_empty()); + api.store + .set_current_ticket_role_assignment( + &TicketRoleAssignmentRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + ticket_id: original.input.ticket_id.clone(), + assignment_id: "attention-reassigned".into(), + role: TicketAssignmentRole::Orchestrator, + principal: TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + assigned_by: "test-user".into(), + assigned_at: "2026-09-02T00:00:00Z".into(), + }, + Some(&original.input.assignment_id), + "attention-reassign-event", + "attention-reassign-op", + true, + ) + .unwrap(); + dispatch_orchestrator_assignment_attention(&api, &worker); + assert_eq!( + execution.take_inputs().len(), + 1, + "explicit new assignment must deliver" + ); + let accepted = attention.lock().unwrap().accepted.clone().unwrap(); + assert_ne!(original.input.assignment_id, accepted.input.assignment_id); + assert_ne!(original.request_id, accepted.request_id); + assert!(attention_pending(&api).is_empty()); + } +} + +#[tokio::test] +async fn orchestrator_running_to_idle_recovers_unreceived_input_without_notification_memory() { + let (_dir, api, execution) = attention_test_api().await; + let backend = browser_ticket_backend(&api).unwrap(); + let started = start_attention_test_orchestrator(&api).await; + assert!(started.online); + assert_eq!(execution.take_inputs().len(), 1); + let worker_id = started.worker.unwrap().worker_id; + let worker = RuntimeWorkerRef::new(EMBEDDED_WORKER_RUNTIME_ID, &worker_id); + let attention = orchestrator_attention_state(&api, &worker); + let acknowledged = attention.lock().unwrap().accepted.clone().unwrap(); + assert_eq!(acknowledged.fingerprint, acknowledged.input.delivery_id); + attention.lock().unwrap().accepted = None; + use protocol::subscription::SubscriptionWorkerState as S; + maybe_dispatch_orchestrator_turn_end(&api, &worker_id, Some(S::Running), S::Idle); + assert!( + execution.take_inputs().is_empty(), + "lost memory cannot resend durable acknowledgement" + ); + assert!(attention.lock().unwrap().accepted.is_none()); + let ticket_ref = attention_assign_ticket(&api, "Recover unreceived work"); + let pending = attention_pending(&api); + assert_eq!(pending.len(), 1); + assert_eq!(pending[0].ticket_id, ticket_ref.id); + // Snapshot/reconnection and non-turn transitions cannot stand in for startup. + for previous in [None, Some(S::Idle), Some(S::Stopped), Some(S::Paused)] { + maybe_dispatch_orchestrator_turn_end(&api, &worker_id, previous, S::Idle); + assert!(attention.lock().unwrap().accepted.is_none()); + assert!(execution.take_inputs().is_empty()); + assert_eq!(attention_pending(&api).len(), 1); + } + maybe_dispatch_orchestrator_turn_end(&api, &worker_id, Some(S::Running), S::Idle); + let accepted = attention.lock().unwrap().accepted.clone().unwrap(); + assert_eq!(accepted.input.assignment_id, pending[0].assignment_id); + assert_eq!(accepted.input.input_event_id, pending[0].input_event_id); + assert_eq!(accepted.fingerprint, accepted.input.delivery_id); + assert!(attention_pending(&api).is_empty()); + maybe_dispatch_orchestrator_turn_end(&api, &worker_id, Some(S::Running), S::Idle); + let notifications = execution.take_inputs(); + assert_eq!(notifications.len(), 1); + assert_eq!(notifications[0].0.worker_id.to_string(), worker_id); + let content = ¬ifications[0].1; + assert!(content.starts_with("Assigned Tickets have unreceived input:")); + assert!( + content.contains(&format!( + "- {} — Recover unreceived work", + ticket_ref.resource_key.as_deref().unwrap() + )), + "unexpected notification body: {content:?}" + ); + assert!(content.contains("Reread the current Ticket state before acting")); + assert!(!content.contains(TEST_WORKSPACE_ID)); + assert!(!content.contains("bounded")); + assert!(!content.contains("omitted")); + + let candidates = backend + .list(ticket::TicketListQuery::states([ + ticket::TicketListState::Open, + ])) + .unwrap(); + let mut truncated_candidates = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS + 1) + .map(|index| { + let mut candidate = candidates[0].clone(); + candidate.id = format!("opaque-{index}"); + candidate.resource_key = Some(format!("T-{index}")); + candidate.title = format!("Queued {index}"); + candidate + }) + .collect::>(); + let truncated = orchestrator_queue_attention_context( + TEST_WORKSPACE_ID, + TEST_WORKSPACE_ID, + &truncated_candidates, + ) + .unwrap(); + let rendered = worker::PromptCatalog::builtins_only() + .unwrap() + .orchestrator_queue_attention(worker::OrchestratorQueueAttentionPrompt::Server, &truncated) + .unwrap(); + assert!(rendered.contains("- T-20 — Queued 20")); + assert!(!rendered.contains("T-21")); + assert!(rendered.contains("were omitted from this notice: 1")); + assert!(!rendered.contains("opaque-")); + + truncated_candidates[0].resource_key = None; + assert_eq!( + orchestrator_queue_attention_context( + TEST_WORKSPACE_ID, + TEST_WORKSPACE_ID, + &truncated_candidates + ) + .unwrap_err(), + "missing_ticket_resource_key" + ); + assert_eq!( + orchestrator_queue_attention_context( + TEST_WORKSPACE_ID, + "foreign-workspace", + &truncated_candidates + ) + .unwrap_err(), + "foreign_workspace_ticket_projection" + ); +} diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs new file mode 100644 index 000000000..e43bac8fe --- /dev/null +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -0,0 +1,1137 @@ +// Included as a child module of server::tests. These tests exercise committed +// Assign + HTTP + embedded Runtime boundaries, not the old Queue predicate. +use super::*; +use session_store::{LogEntry, Store, WorkerSessionStore}; +use worker_runtime::execution as delivery_execution; + +fn delivery_assignment_path(ticket: &str) -> String { + format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator") +} + +async fn delivery_http( + api: &WorkspaceApi, + method: &str, + path: &str, + body: Option, + expected: StatusCode, +) -> Value { + let token = format!("api-token-{TEST_WORKSPACE_ID}"); + let response = build_router(api.clone()) + .oneshot( + Request::builder() + .method(method) + .uri(path) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(body.map(|v| v.to_string()).unwrap_or_default())) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + expected, + "{method} {path}: {}", + String::from_utf8_lossy(&bytes) + ); + if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + } +} + +async fn delivery_assign(api: &WorkspaceApi, ticket: &str, key: &str) -> Value { + delivery_http( + api, + "PUT", + &delivery_assignment_path(ticket), + Some(json!({ + "operation_id": key, + "principal": {"kind":"workspace_agent", "agent_key":"workspace-orchestrator"} + })), + StatusCode::OK, + ) + .await +} + +fn delivery_ticket(api: &WorkspaceApi, title: &str) -> ticket::TicketRef { + browser_ticket_backend(api) + .unwrap() + .create(ticket::NewTicket::new(title)) + .unwrap() +} + +fn delivery_tracking( + api: &WorkspaceApi, + ticket: &str, + assignment: &str, +) -> ticket::TicketAssignmentTrackingProjection { + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket, assignment) + .unwrap() + .unwrap() +} + +fn delivery_worker(response: BrowserWorkspaceOrchestratorResponse) -> RuntimeWorkerRef { + assert!(response.online); + RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + response.worker.unwrap().worker_id, + ) +} + +fn delivery_scan(api: &WorkspaceApi, worker: &RuntimeWorkerRef) { + dispatch_orchestrator_assignment_attention(api, worker); + use protocol::subscription::SubscriptionWorkerState as S; + maybe_dispatch_orchestrator_turn_end(api, &worker.worker_id, Some(S::Running), S::Idle); +} + +fn delivery_stop(api: &WorkspaceApi, worker: &RuntimeWorkerRef) { + api.runtime + .stop_worker( + worker, + WorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }, + ) + .unwrap(); +} + +async fn delivery_recording_fixture( + root: &Path, +) -> ( + WorkspaceApi, + Arc, + RuntimeWorkerRef, + RuntimeIdentityMaterial, +) { + let (mut api, execution) = test_api_with_recording_backend(root).await; + seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let worker = delivery_worker(start_attention_test_orchestrator(&api).await); + let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap(); + configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID); + seed_worker_source_member(&api, &worker.runtime_id, &worker.worker_id); + assert!( + execution.take_inputs().is_empty(), + "open tickets are not implicit delegations" + ); + (api, execution, worker, identity) +} + +async fn delivery_self_report( + api: &WorkspaceApi, + identity: &RuntimeIdentityMaterial, + worker: &RuntimeWorkerRef, + current: &ticket::TicketAssignmentTrackingProjection, + status: &str, + key: &str, +) -> Value { + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/assignment-tracking", + current.ticket_id + ); + let body = serde_json::to_vec(&json!({ + "assignment_id":current.assignment_id, + "expected_last_event_id":current.last_event_id, + "operation_key":key, + "tracking": {"status":status, "waiting_reason":"Need the human's choice", + "progress":"Options investigated", "next_action":"Read the answer then decide", + "result": if status == "done" { Some("Returned options without closing") } else { None }} + })).unwrap(); + let response = build_router(api.clone()) + .oneshot(runtime_source_request( + identity, + Some(&worker.worker_id), + "POST", + &path, + body, + )) + .await + .unwrap(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::OK, + "{}", + String::from_utf8_lossy(&bytes) + ); + serde_json::from_slice(&bytes).unwrap() +} + +async fn delivery_external_answer(api: &WorkspaceApi, ticket: &str, text: &str, key: &str) { + let current = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket) + .unwrap() + .remove(0); + delivery_http(api,"POST",&format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer"),Some(json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":key,"body":text})),StatusCode::OK).await; +} + +fn delivery_commit_answer_without_scan(api: &WorkspaceApi, ticket: &str, text: &str, key: &str) { + seed_test_api_token(api.store.as_ref(), "direct-answer"); + let current = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, ticket) + .unwrap() + .remove(0); + api.store + .answer_ticket_assignment( + TEST_WORKSPACE_ID, + ticket, + &ticket::TicketAssignmentAnswer { + assignment_id: current.assignment_id, + expected_last_event_id: current.last_event_id.unwrap(), + operation_key: key.into(), + body: text.into(), + }, + &crate::store::TicketOrchestratorAssignmentSourceContext::Account { + account_id: "account-direct-answer".into(), + user_id: "user-direct-answer".into(), + }, + "fixture-account", + ) + .unwrap(); +} + +fn delivery_external_comment(api: &WorkspaceApi, ticket: &str, text: &str) { + browser_ticket_backend(api) + .unwrap() + .add_event( + ticket.to_owned().into(), + ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, text), + ) + .unwrap(); + notify_ticket_recipients(api, TEST_WORKSPACE_ID, ticket, "open", "open", None); +} + +#[tokio::test] +async fn assignment_waiting_self_report_does_not_loop_but_external_answer_is_delivered() { + let dir = tempfile::tempdir().unwrap(); + let (api, execution, worker, identity) = delivery_recording_fixture(dir.path()).await; + let ticket = delivery_ticket(&api, "Choose an option"); + let assigned = delivery_assign(&api, &ticket.id, "assign-choice").await; + let assignment = assigned["assignment"]["assignment_id"].as_str().unwrap(); + let initial = delivery_tracking(&api, &ticket.id, assignment); + assert_eq!( + initial.tracking.as_ref().unwrap().status, + ticket::TicketAssignmentTrackingStatus::Waiting + ); + let first = execution.take_inputs(); + assert_eq!(first.len(), 1, "Assign alone delivers without Queue"); + let first_ids = execution.take_input_request_ids(); + assert!(first_ids[0].is_some()); + assert!(first[0].1.contains(ticket.resource_key.as_deref().unwrap())); + let reported = + delivery_self_report(&api, &identity, &worker, &initial, "waiting", "wait-human").await; + // Even an explicit notification of one's own mutation must not manufacture + // fresh work, and repeated idle/startup scans must not use waiting as a queue. + notify_ticket_recipients( + &api, + TEST_WORKSPACE_ID, + &ticket.id, + "open", + "open", + Some(worker.clone()), + ); + for _ in 0..3 { + delivery_scan(&api, &worker); + } + assert!( + execution.take_inputs().is_empty(), + "self progress became an input loop" + ); + let waiting = delivery_tracking(&api, &ticket.id, assignment); + assert_eq!(serde_json::to_value(&waiting).unwrap(), reported); + delivery_external_comment(&api, &ticket.id, "History-only annotation while waiting"); + assert!( + execution.take_inputs().is_empty(), + "ordinary waiting comment became fresh work" + ); + delivery_external_answer( + &api, + &ticket.id, + "Human answer: choose option B", + "choose-B", + ) + .await; + let answer = execution.take_inputs(); + assert_eq!( + answer.len(), + 1, + "external answer must wake the existing delegation" + ); + assert_ne!(execution.take_input_request_ids(), first_ids); + assert!( + answer[0] + .1 + .contains(ticket.resource_key.as_deref().unwrap()) + ); + assert!( + answer[0].1.contains("Need the human's choice"), + "waiting context missing: {:?}", + answer[0].1 + ); + assert!(answer[0].1.contains("Read the answer then decide")); + for _ in 0..3 { + delivery_scan(&api, &worker); + } + assert!( + execution.take_inputs().is_empty(), + "same answer was delivered again" + ); + assert_eq!( + delivery_tracking(&api, &ticket.id, assignment), + waiting, + "delivery/idle must not infer handling inprogress or done" + ); + delivery_stop(&api, &worker); +} + +#[tokio::test] +async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_new_delegation() { + let dir = tempfile::tempdir().unwrap(); + let (api, execution, worker, identity) = delivery_recording_fixture(dir.path()).await; + let ticket = delivery_ticket(&api, "Return options while open"); + let assigned = delivery_assign(&api, &ticket.id, "assign-return").await; + let assignment = assigned["assignment"]["assignment_id"].as_str().unwrap(); + execution.take_inputs(); + execution.take_input_request_ids(); + let initial = delivery_tracking(&api, &ticket.id, assignment); + delivery_self_report(&api, &identity, &worker, &initial, "done", "return-options").await; + let done = delivery_tracking(&api, &ticket.id, assignment); + assert_eq!( + api.authority.ticket(&ticket.id).unwrap().state.as_str(), + "open" + ); + delivery_external_comment( + &api, + &ticket.id, + "Historical result annotation, not a new request", + ); + for _ in 0..3 { + delivery_scan(&api, &worker); + } + assert!( + execution.take_inputs().is_empty(), + "open + done must not execute history" + ); + assert_eq!(delivery_tracking(&api, &ticket.id, assignment), done); + for (key, action) in [ + ( + "close-return", + json!({"action":"close", "reason":"not_planned"}), + ), + ("reopen-return", json!({"action":"reopen"})), + ] { + let observed = api.authority.ticket(&ticket.id).unwrap(); + delivery_http( + &api, + "POST", + &format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", ticket.id), + Some(json!({"expected_content_digest":observed.content_digest, + "expected_state":observed.state, + "expected_closure_operation_key":observed.closure_operation_key, + "operation_key":key, "action":action, "explanation":"Human case decision"})), + StatusCode::OK, + ) + .await; + delivery_scan(&api, &worker); + assert!( + execution.take_inputs().is_empty(), + "case history must not revive done delegation" + ); + assert_eq!(delivery_tracking(&api, &ticket.id, assignment), done); + } + let reassigned = delivery_assign(&api, &ticket.id, "reissue-return").await; + let new_assignment = reassigned["assignment"]["assignment_id"].as_str().unwrap(); + assert_ne!(new_assignment, assignment); + assert_eq!( + execution.take_inputs().len(), + 1, + "explicit reissue must be reconsidered" + ); + let retained = delivery_tracking(&api, &ticket.id, assignment); + assert!(!retained.current); + assert_eq!( + retained.tracking, done.tracking, + "new request overwrote the old result" + ); + assert_eq!( + delivery_tracking(&api, &ticket.id, new_assignment) + .tracking + .unwrap() + .status, + ticket::TicketAssignmentTrackingStatus::Waiting + ); + // An exact old Assign retry returns its receipt, never revives old work. + assert_eq!( + delivery_assign(&api, &ticket.id, "assign-return").await, + assigned + ); + delivery_scan(&api, &worker); + assert!(execution.take_inputs().is_empty()); + delivery_stop(&api, &worker); +} + +#[tokio::test] +async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stopping_worker() { + for clear_first in [true, false] { + let dir = tempfile::tempdir().unwrap(); + let (api, execution, worker, _) = delivery_recording_fixture(dir.path()).await; + let ticket = delivery_ticket(&api, "Fence old delivery"); + execution.reject_inputs("unknown acceptance response"); + let assigned = delivery_assign(&api, &ticket.id, "assign-old").await; + let assignment = assigned["assignment"]["assignment_id"].as_str().unwrap(); + let old_attempts = execution.take_inputs(); + let old_ids = execution.take_input_request_ids(); + assert!(!old_attempts.is_empty()); + assert!(old_ids.iter().all(|id| id == &old_ids[0])); + if clear_first { + delivery_http( + &api, + "DELETE", + &format!( + "{}?assignment_id={assignment}&operation_id=clear-old", + delivery_assignment_path(&ticket.id) + ), + None, + StatusCode::OK, + ) + .await; + } + *execution.input_failure.lock().unwrap() = None; + // No extra scan, old-operation retry, or synthetic turn end between + // clearing/reissuing the failed input and this committed new Assign. + let reassigned = delivery_assign(&api, &ticket.id, "assign-new").await; + let new_assignment = reassigned["assignment"]["assignment_id"].as_str().unwrap(); + assert_ne!(new_assignment, assignment); + let inputs = execution.take_inputs(); + assert_eq!(inputs.len(), 1, "new Assign must not need another scan"); + assert!(inputs[0].1.contains(new_assignment)); + let ids = execution.take_input_request_ids(); + assert_eq!(ids.len(), 1); + assert_ne!( + ids[0], old_ids[0], + "new assignment reused stale input identity" + ); + assert_eq!( + delivery_assign(&api, &ticket.id, "assign-old").await, + assigned + ); + assert!(execution.take_inputs().is_empty(), "old retry revived work"); + let old = delivery_tracking(&api, &ticket.id, assignment); + assert!(!old.current); + assert_eq!( + old.tracking.unwrap().status, + ticket::TicketAssignmentTrackingStatus::Waiting, + "clear/reissue must not rewrite the old status to done" + ); + assert_eq!( + find_workspace_orchestrator(&api).unwrap().worker, + worker, + "clear/reassign are not Worker stop or replacement" + ); + delivery_stop(&api, &worker); + } +} + +#[tokio::test] +async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_another_trigger() { + for answer in [false, true] { + let dir = tempfile::tempdir().unwrap(); + let (api, execution, worker, identity) = delivery_recording_fixture(dir.path()).await; + let ticket = delivery_ticket(&api, "Input racing an empty scan"); + if answer { + let assigned = delivery_assign(&api, &ticket.id, "initial-assign").await; + let current = delivery_tracking( + &api, + &ticket.id, + assigned["assignment"]["assignment_id"].as_str().unwrap(), + ); + delivery_self_report(&api, &identity, &worker, ¤t, "waiting", "wait-answer") + .await; + execution.take_inputs(); + execution.take_input_request_ids(); + } + assert!(attention_pending(&api).is_empty()); + let (entered_tx, entered_rx) = std::sync::mpsc::channel(); + let (release_tx, release_rx) = std::sync::mpsc::channel(); + std::thread::scope(|scope| { + let caller_api = api.clone(); + let caller_worker = worker.clone(); + let caller = scope.spawn(move || { + let mut checks = 0; + with_orchestrator_attention_reservation( + &caller_api, + &caller_worker, + true, + |state, pending| { + check_orchestrator_assignment_attention( + &caller_api, + &caller_worker, + state, + pending, + ); + if checks == 0 { + // The production scan has read an empty snapshot but + // still owns its reservation. Race a real HTTP commit. + entered_tx.send(()).unwrap(); + release_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + } + checks += 1; + }, + ); + assert_eq!(checks, 2, "committed input must defer a scan"); + }); + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); + if answer { + futures::executor::block_on(delivery_external_answer( + &api, + &ticket.id, + "Human answer: go ahead", + "racing-answer", + )); + } else { + futures::executor::block_on(delivery_assign(&api, &ticket.id, "racing-assign")); + } + // No manual dispatch, Restore, or synthetic Running -> Idle. + assert!(execution.take_inputs().is_empty()); + release_tx.send(()).unwrap(); + caller.join().unwrap(); + }); + let inputs = execution.take_inputs(); + assert_eq!(inputs.len(), 1, "idle Orchestrator was stranded"); + assert!( + inputs[0] + .1 + .contains(ticket.resource_key.as_deref().unwrap()) + ); + if answer { + assert!(inputs[0].1.contains("Need the human's choice")); + } + assert_eq!(execution.take_input_request_ids().len(), 1); + assert!(attention_pending(&api).is_empty()); + delivery_stop(&api, &worker); + } +} + +#[tokio::test] +async fn assignment_failed_delivery_and_its_reentrant_idle_do_not_schedule_retry_loop() { + let dir = tempfile::tempdir().unwrap(); + let (api, execution, worker, _) = delivery_recording_fixture(dir.path()).await; + let ticket = delivery_ticket(&api, "No failed-delivery busy loop"); + execution.reject_inputs("Runtime currently cannot accept notifications"); + let callback_api = api.clone(); + execution.before_input_returns(move |worker| { + use protocol::subscription::SubscriptionWorkerState as S; + maybe_dispatch_orchestrator_turn_end( + &callback_api, + &worker.worker_id.to_string(), + Some(S::Running), + S::Idle, + ); + }); + delivery_assign(&api, &ticket.id, "assign-failed").await; + assert_eq!( + execution.take_inputs().len(), + 2, + "idle must not add retries beyond the bounded send" + ); + let ids = execution.take_input_request_ids(); + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], ids[1]); + assert_eq!( + attention_pending(&api).len(), + 1, + "failure must retain durable input" + ); + delivery_stop(&api, &worker); +} + +// The model boundary is scripted, but Controller, notification acceptance, +// canonical session commits, Runtime management and HTTP routing are real. +#[derive(Clone, Default)] +struct DeliveryScriptedClient { + requests: Arc>>, +} + +#[async_trait::async_trait] +impl agen::llm_client::LlmClient for DeliveryScriptedClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + async fn stream( + &self, + request: agen::llm_client::Request, + ) -> std::result::Result + { + self.requests.lock().unwrap().push(request); + use agen::llm_client::event::Event as LlmEvent; + Ok(Box::pin(futures::stream::iter( + [ + LlmEvent::text_block_start(0), + LlmEvent::text_delta( + 0, + "Read the delegation; returning without starting a Coder.", + ), + LlmEvent::text_block_stop(0, None), + ] + .into_iter() + .map(Ok), + ))) + } +} + +struct DeliveryWorkerFactory { + root: PathBuf, + client: DeliveryScriptedClient, +} + +impl DeliveryWorkerFactory { + async fn controller( + &self, + worker_id: String, + restore: bool, + ) -> std::result::Result { + let name = format!("delivery-{worker_id}"); + let root = self.root.join("workers").join(&worker_id); + let store = session_store::CombinedStore::new( + WorkerSessionStore::new(root.join("session")).map_err(|e| e.to_string())?, + session_store::WorkerAggregateStore::new(&root, &name).map_err(|e| e.to_string())?, + ); + let manifest = manifest::WorkerManifest::from_toml(&format!( + r#" + [worker] + name = "{name}" + pwd = "./" + [model] + scheme = "anthropic" + model_id = "scripted-delivery" + auth = {{ kind = "none" }} + [engine] + max_tokens = 64 + [scope] + allow = [] + "# + )) + .map_err(|e| e.to_string())?; + let context = worker::WorkerWorkspaceContext::local_filesystem(None); + let workspace_client = context.client_handle(); + let bootstrap = worker::WorkerBootstrap::new( + manifest, + store, + worker::PromptCatalogSource::builtins_only(), + context, + worker::WorkerFilesystemAuthority::None, + worker::WorkerBootstrapLayout::RuntimeManagedRun { + run_dir: root.join(uuid::Uuid::now_v7().to_string()), + bash_output_dir: root.join("bash-output"), + }, + worker::WorkerControllerTransport::InProcess, + ) + .with_model_client(self.client.clone()); + let prepared = if restore { + bootstrap.prepare_restored(&name).await + } else { + bootstrap.prepare().await + } + .map_err(|e| e.to_string())?; + let started = prepared.start().await.map_err(|e| e.to_string())?; + Ok(worker_runtime::worker_backend::RuntimeWorkerController { + handle: started.handle, + shutdown: Arc::new(tokio::sync::Mutex::new(Some(started.shutdown))), + controller_task: started.controller_task, + workspace_client, + }) + } +} + +#[async_trait::async_trait] +impl worker_runtime::worker_backend::RuntimeWorkerFactory for DeliveryWorkerFactory { + async fn spawn_controller( + &self, + request: delivery_execution::WorkerExecutionSpawnRequest, + ) -> std::result::Result { + self.controller(request.worker_ref.worker_id.to_string(), false) + .await + } + async fn restore_controller( + &self, + request: delivery_execution::WorkerExecutionRestoreRequest, + ) -> std::result::Result { + self.controller(request.worker_ref.worker_id.to_string(), true) + .await + } +} + +// Lose the response only AFTER the real Worker accepts the exact input. Unlike +// a pre-send rejection, this exercises durable Worker receipt deduplication. +struct DeliveryResponseLossBackend { + inner: Arc, + lose_responses: std::sync::atomic::AtomicBool, + attempts: Mutex>, +} + +impl delivery_execution::WorkerExecutionBackend for DeliveryResponseLossBackend { + fn backend_id(&self) -> &str { + self.inner.backend_id() + } + fn spawn_worker( + &self, + r: delivery_execution::WorkerExecutionSpawnRequest, + ) -> delivery_execution::WorkerExecutionSpawnResult { + self.inner.spawn_worker(r) + } + fn preflight_restore( + &self, + r: &delivery_execution::WorkerExecutionRestoreRequest, + ) -> std::result::Result<(), delivery_execution::WorkerExecutionResult> { + self.inner.preflight_restore(r) + } + fn restore_worker( + &self, + r: delivery_execution::WorkerExecutionRestoreRequest, + ) -> delivery_execution::WorkerExecutionSpawnResult { + self.inner.restore_worker(r) + } + fn reconcile_restore( + &self, + r: delivery_execution::WorkerExecutionRestoreRequest, + ) -> delivery_execution::WorkerExecutionSpawnResult { + self.inner.reconcile_restore(r) + } + fn activate_restored_worker( + &self, + id: delivery_execution::WorkerLifecycleOperationId, + worker: &worker_runtime::identity::WorkerRef, + ) -> std::result::Result<(), String> { + self.inner.activate_restored_worker(id, worker) + } + fn dispatch_input( + &self, + worker: &worker_runtime::identity::WorkerRef, + input: worker_runtime::interaction::WorkerInput, + ) -> delivery_execution::WorkerExecutionResult { + self.attempts.lock().unwrap().push(( + input.submission_request_id.clone().unwrap(), + input.content.clone(), + )); + let result = self.inner.dispatch_input(worker, input); + if result.is_accepted() + && self + .lose_responses + .load(std::sync::atomic::Ordering::SeqCst) + { + delivery_execution::WorkerExecutionResult::errored( + delivery_execution::WorkerExecutionOperation::Input, + "accepted response lost", + ) + } else { + result + } + } + fn dispatch_method( + &self, + worker: &worker_runtime::identity::WorkerRef, + method: protocol::Method, + ) -> delivery_execution::WorkerExecutionResult { + self.inner.dispatch_method(worker, method) + } + fn stop_worker_operation( + &self, + r: delivery_execution::WorkerExecutionStopRequest, + ) -> delivery_execution::WorkerExecutionResult { + self.inner.stop_worker_operation(r) + } + fn worker_snapshot( + &self, + worker: &worker_runtime::identity::WorkerRef, + ) -> Option { + self.inner.worker_snapshot(worker) + } +} + +async fn delivery_real_fixture( + root: &Path, + lose_responses: bool, +) -> ( + WorkspaceApi, + Arc, + DeliveryScriptedClient, +) { + let config = test_server_config(root); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); + seed_test_registered_workspace(store.as_ref(), &config) + .await + .unwrap(); + let client = DeliveryScriptedClient::default(); + let inner = Arc::new( + worker_runtime::worker_backend::WorkerRuntimeExecutionBackend::new(DeliveryWorkerFactory { + root: config.embedded_runtime_store_root.clone(), + client: client.clone(), + }) + .unwrap(), + ); + let execution = Arc::new(DeliveryResponseLossBackend { + inner, + lose_responses: std::sync::atomic::AtomicBool::new(lose_responses), + attempts: Mutex::new(Vec::new()), + }); + let mut api = WorkspaceApi::new_with_execution_backend(config, store, execution.clone()) + .await + .unwrap(); + // The injectable constructor does not install production Runtime trust. + // Delivery resolves the registered singleton plus its active binding. + let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap(); + configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID); + seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + (api, execution, client) +} + +fn delivery_session_entries(api: &WorkspaceApi, worker: &RuntimeWorkerRef) -> Vec { + let session = WorkerSessionStore::open_read_only( + api.config + .embedded_runtime_store_root + .join("workers") + .join(&worker.worker_id) + .join("session"), + ) + .unwrap(); + let id = session.session_id().unwrap().unwrap(); + session + .list_segments(id) + .unwrap() + .into_iter() + .flat_map(|segment| session.read_all_read_only(id, segment).unwrap()) + .collect() +} + +fn delivery_receipts(api: &WorkspaceApi, worker: &RuntimeWorkerRef) -> Vec { + delivery_session_entries(api, worker) + .iter() + .rev() + .find_map(|entry| match entry { + LogEntry::Extension { + domain, payload, .. + } if domain == "worker.pending_activations.v1" => { + Some(payload["notification_receipts"].as_array().unwrap().clone()) + } + LogEntry::AnnotatedSystemItem { extensions, .. } => extensions + .iter() + .find(|e| e.domain == "worker.pending_activations.v1") + .map(|e| { + e.payload["notification_receipts"] + .as_array() + .unwrap() + .clone() + }), + _ => None, + }) + .unwrap_or_default() +} + +fn delivery_commits(api: &WorkspaceApi, worker: &RuntimeWorkerRef) -> Vec { + delivery_session_entries(api, worker) + .into_iter() + .filter_map(|entry| match entry { + LogEntry::AnnotatedSystemItem { entry, .. } => match entry.item { + session_store::SystemItem::Notification { message, .. } => Some(message), + _ => None, + }, + _ => None, + }) + .collect() +} + +async fn delivery_wait_idle( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + expected_commits: usize, +) { + tokio::time::timeout(std::time::Duration::from_secs(10), async { + loop { + let commits = delivery_commits(api, worker).len(); + assert!( + commits <= expected_commits, + "duplicate canonical delivery: {:?}", + delivery_commits(api, worker) + ); + let entries = delivery_session_entries(api, worker); + let last_notification = entries.iter().rposition(|entry| matches!( + entry, + LogEntry::AnnotatedSystemItem { entry, .. } + if matches!(entry.item, session_store::SystemItem::Notification { .. }) + )); + let last_completed = entries.iter().rposition(|entry| matches!(entry, LogEntry::RunCompleted { .. })); + // Runtime acceptance and the saved notification precede processing. + // Initial Idle can therefore be observed before any model request. + // Require a committed run outcome after the latest input, not a + // guessed delay or Worker liveness as proof of consumption. + let processed = last_notification.zip(last_completed) + .is_some_and(|(input, completed)| completed > input); + let summary = api.runtime.worker(worker).unwrap(); + if commits == expected_commits + && processed + && summary + .worker_state + .as_ref() + .is_some_and(|state| state.state == protocol::WorkerState::Idle) + { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .unwrap_or_else(|_| panic!("scripted Worker did not finish: expected_commits={expected_commits}, commits={:?}, receipts={:?}, worker={:?}", + delivery_commits(api, worker), delivery_receipts(api, worker), api.runtime.worker(worker))); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn assignment_without_queue_reaches_real_worker_and_stopped_assign_survives_restore() { + let dir = tempfile::tempdir().unwrap(); + let (api, execution, client) = delivery_real_fixture(dir.path(), false).await; + let first = delivery_ticket(&api, "Assign alone is the delegation"); + let assigned = delivery_assign(&api, &first.id, "assign-before-start").await; + let assignment = assigned["assignment"]["assignment_id"].as_str().unwrap(); + let initial = delivery_tracking(&api, &first.id, assignment); + assert!( + execution.attempts.lock().unwrap().is_empty(), + "Assign must not auto-restore a stopped Orchestrator" + ); + let worker = delivery_worker(start_attention_test_orchestrator(&api).await); + delivery_wait_idle(&api, &worker, 1).await; + assert_eq!(delivery_receipts(&api, &worker).len(), 1); + assert!(delivery_commits(&api, &worker)[0].contains(first.resource_key.as_deref().unwrap())); + assert_eq!( + client.requests.lock().unwrap().len(), + 1, + "real scripted model did not read the request" + ); + assert_eq!(delivery_tracking(&api, &first.id, assignment), initial); + delivery_stop(&api, &worker); + let second = delivery_ticket(&api, "Assigned while stopped"); + let second_assigned = delivery_assign(&api, &second.id, "assign-while-stopped").await; + let second_id = second_assigned["assignment"]["assignment_id"] + .as_str() + .unwrap(); + let second_initial = delivery_tracking(&api, &second.id, second_id); + assert_eq!(execution.attempts.lock().unwrap().len(), 1); + let restored = start_attention_test_orchestrator(&api).await; + assert_eq!(restored.disposition, "restored"); + assert_eq!(delivery_worker(restored), worker); + delivery_wait_idle(&api, &worker, 2).await; + assert_eq!(delivery_receipts(&api, &worker).len(), 2); + assert!(delivery_commits(&api, &worker)[1].contains(second.resource_key.as_deref().unwrap())); + assert_eq!( + delivery_tracking(&api, &second.id, second_id), + second_initial + ); + let accepted = execution.attempts.lock().unwrap().clone(); + delivery_scan(&api, &worker); + delivery_stop(&api, &worker); + assert_eq!( + start_attention_test_orchestrator(&api).await.disposition, + "restored" + ); + delivery_scan(&api, &worker); + assert_eq!( + execution.attempts.lock().unwrap().as_slice(), + accepted.as_slice(), + "restoring the same Worker must retain durable acceptance, not generate new work" + ); + assert_eq!(delivery_commits(&api, &worker).len(), 2); + assert_eq!(delivery_receipts(&api, &worker).len(), 2); + delivery_stop(&api, &worker); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn assignment_real_worker_response_loss_restore_and_concurrent_scans_commit_once() { + let dir = tempfile::tempdir().unwrap(); + let (mut api, execution, _) = delivery_real_fixture(dir.path(), true).await; + let identity = RuntimeIdentityMaterial::generate(EMBEDDED_WORKER_RUNTIME_ID).unwrap(); + configure_runtime_request_auth(&mut api, &identity, EMBEDDED_WORKER_RUNTIME_ID); + let ticket = delivery_ticket(&api, "Immutable lost-response payload"); + let assigned = delivery_assign(&api, &ticket.id, "assign-lost-response").await; + let assignment = assigned["assignment"]["assignment_id"].as_str().unwrap(); + let initial = delivery_tracking(&api, &ticket.id, assignment); + let worker = delivery_worker(start_attention_test_orchestrator(&api).await); + delivery_wait_idle(&api, &worker, 1).await; + let attempts = execution.attempts.lock().unwrap().clone(); + assert!(!attempts.is_empty()); + assert!( + attempts.iter().all(|attempt| attempt == &attempts[0]), + "uncertain delivery changed request id or payload: {attempts:?}" + ); + assert_eq!(delivery_receipts(&api, &worker).len(), 1); + // Change mutable rendering context while the acknowledgement is unknown. + // A new self-report is not a new input and cannot change the frozen retry. + seed_worker_source_member(&api, &worker.runtime_id, &worker.worker_id); + delivery_self_report( + &api, + &identity, + &worker, + &initial, + "waiting", + "waiting-during-response-loss", + ) + .await; + let waiting = delivery_tracking(&api, &ticket.id, assignment); + delivery_stop(&api, &worker); + execution + .lose_responses + .store(false, std::sync::atomic::Ordering::SeqCst); + browser_ticket_backend(&api) + .unwrap() + .edit_item( + ticket.id.clone().into(), + ticket::TicketItemEdit { + title: Some("Title changed while acceptance was unknown".into()), + ..Default::default() + }, + ) + .unwrap(); + // Reopen while acceptance is still unknown. This intentionally discards the + // old API's cached pending text, but not the durable input/Worker receipts. + let config = api.config.clone(); + drop(api); + tokio::task::yield_now().await; + let reopened = WorkspaceApi::new_with_execution_backend( + config.clone(), + Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()), + execution.clone(), + ) + .await + .unwrap(); + let restored = start_attention_test_orchestrator(&reopened).await; + assert_eq!(restored.disposition, "restored"); + assert_eq!(delivery_worker(restored), worker); + { + let after_restart = execution.attempts.lock().unwrap(); + let same_request = after_restart + .iter() + .filter(|attempt| attempt.0 == attempts[0].0) + .collect::>(); + assert!( + same_request.len() > attempts.len(), + "unknown outcome was not retried after restore" + ); + assert!( + same_request.iter().all(|attempt| *attempt == &attempts[0]), + "same request id changed payload after DB reopen/title/self-report change: {after_restart:?}" + ); + } + delivery_wait_idle(&reopened, &worker, 2).await; + assert_eq!(delivery_receipts(&reopened, &worker).len(), 2); + assert_eq!( + delivery_commits(&reopened, &worker) + .iter() + .filter(|message| *message == &attempts[0].1) + .count(), + 1, + "accepted retry committed the old notification twice" + ); + assert_eq!( + delivery_tracking(&reopened, &ticket.id, assignment), + waiting + ); + let before = execution.attempts.lock().unwrap().len(); + delivery_commit_answer_without_scan( + &reopened, + &ticket.id, + "External answer after server restart", + "answer-after-restart", + ); + let barrier = Arc::new(std::sync::Barrier::new(8)); + std::thread::scope(|scope| { + let joins = (0..8) + .map(|_| { + let api = reopened.clone(); + let worker = worker.clone(); + let barrier = barrier.clone(); + scope.spawn(move || { + barrier.wait(); + dispatch_orchestrator_assignment_attention(&api, &worker); + }) + }) + .collect::>(); + for join in joins { + join.join().unwrap(); + } + }); + delivery_wait_idle(&reopened, &worker, 3).await; + assert_eq!( + execution.attempts.lock().unwrap().len(), + before + 1, + "concurrent scans must share the committed reservation" + ); + assert_eq!(delivery_receipts(&reopened, &worker).len(), 3); + delivery_scan(&reopened, &worker); + assert_eq!(execution.attempts.lock().unwrap().len(), before + 1); + assert_eq!( + delivery_tracking(&reopened, &ticket.id, assignment), + waiting + ); + delivery_stop(&reopened, &worker); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn assignment_worker_replacement_receives_pending_input_without_recreating_delegation() { + let dir = tempfile::tempdir().unwrap(); + let (api, execution, _) = delivery_real_fixture(dir.path(), false).await; + let handled = delivery_ticket(&api, "Already received before replacement"); + let saved = delivery_assign(&api, &handled.id, "assign-before-replacement").await; + let assignment = saved["assignment"]["assignment_id"].as_str().unwrap(); + let initial = delivery_tracking(&api, &handled.id, assignment); + let old_worker = delivery_worker(start_attention_test_orchestrator(&api).await); + delivery_wait_idle(&api, &old_worker, 1).await; + delivery_stop(&api, &old_worker); + // Delete only this isolated fixture's stopped identity. A new live Worker + // has a new session; it is not a restore and cannot reuse old session receipts. + api.runtime.delete_worker(&old_worker).unwrap(); + api.worker_projection + .publish_ordered(|| { + api.store + .delete_worker_registry(TEST_WORKSPACE_ID, &old_worker) + .map(|commit| ((), commit)) + }) + .unwrap(); + let pending = delivery_ticket(&api, "Pending input for replacement"); + let new_assign = delivery_assign(&api, &pending.id, "assign-pending-replacement").await; + let pending_assignment = new_assign["assignment"]["assignment_id"].as_str().unwrap(); + let pending_initial = delivery_tracking(&api, &pending.id, pending_assignment); + let started = start_attention_test_orchestrator(&api).await; + assert_eq!(started.disposition, "created"); + let replacement = delivery_worker(started); + assert_ne!(replacement, old_worker); + delivery_wait_idle(&api, &replacement, 1).await; + assert_eq!(delivery_receipts(&api, &replacement).len(), 1); + let commits = delivery_commits(&api, &replacement); + assert!(commits[0].contains(pending.resource_key.as_deref().unwrap())); + assert!( + !commits[0].contains(handled.resource_key.as_deref().unwrap()), + "replacement must not execute acknowledged history" + ); + assert_eq!(execution.attempts.lock().unwrap().len(), 2); + assert_eq!( + delivery_tracking(&api, &handled.id, assignment), + initial, + "Worker replacement must not reassign the stable principal" + ); + assert_eq!( + delivery_tracking(&api, &pending.id, pending_assignment), + pending_initial + ); + delivery_scan(&api, &replacement); + assert_eq!(execution.attempts.lock().unwrap().len(), 2); + delivery_stop(&api, &replacement); +} diff --git a/crates/workspace-server/src/server_assignment_start_tests.rs b/crates/workspace-server/src/server_assignment_start_tests.rs new file mode 100644 index 000000000..11e0cdee6 --- /dev/null +++ b/crates/workspace-server/src/server_assignment_start_tests.rs @@ -0,0 +1,752 @@ +// Include from server.rs's `tests` module as a child module: +// mod assignment_start_tests { include!("server_assignment_start_tests.rs"); } +// These exercise the authenticated router with the existing in-process Runtime +// adapter; they do not start product processes or claim model/E2E coverage. +use super::*; + +struct StartSource { + identity: RuntimeIdentityMaterial, + worker: RuntimeWorkerRef, +} + +async fn start_source(api: &mut WorkspaceApi, runtime: &WorkdirlessFixtureRuntime) -> StartSource { + let runtime_id = WorkdirlessFixtureRuntime::RUNTIME_ID; + let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap(); + configure_runtime_request_auth(api, &identity, runtime_id); + let memory = api + .config_store + .get_workspace_memory_settings(TEST_WORKSPACE_ID) + .unwrap(); + let reservation = api + .config_store + .reserve_worker_create( + TEST_WORKSPACE_ID, + runtime_id, + "start-orchestrator", + "sha256:start-orchestrator", + Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY), + &memory, + ) + .unwrap(); + let mut summary = WorkdirlessFixtureRuntime::worker_summary( + WorkerCreateBinding { + worker_id: reservation.worker_id, + create_fingerprint: reservation.create_fingerprint, + }, + &WorkerSpawnRequest { + requested_worker_name: Some("Start Orchestrator".into()), + singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()), + intent: WorkerSpawnIntent::WorkspaceCompanion, + acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 0, + }, + profile: ProfileSelector::Builtin("builtin:orchestrator".into()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachment_requests: Vec::new(), + resolved_workdir_attachment_requests: Vec::new(), + resolved_workdir_attachments: Vec::new(), + resolved_config_bundle: None, + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: Vec::new(), + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_subjektiv_attached: false, + resolved_control_operation: None, + }, + ); + summary.tags = vec!["orchestrator".into(), "singleton".into()]; + let worker = summary.worker.clone(); + runtime.workers.lock().unwrap().push(summary); + seed_worker_source_member(api, runtime_id, &worker.worker_id); + api.config_store + .complete_worker_create_reservation(TEST_WORKSPACE_ID, worker.worker_id.parse().unwrap()) + .unwrap(); + assert_eq!(find_workspace_orchestrator(api).unwrap().worker, worker); + StartSource { identity, worker } +} + +fn add_unfinished_start_dependency(api: &WorkspaceApi, ticket_id: &str) -> String { + let backend = browser_ticket_backend(api).unwrap(); + let dependency = backend + .create(ticket::NewTicket::new("Unfinished prerequisite")) + .unwrap(); + backend + .add_ticket_relation( + ticket_id.into(), + ticket::NewTicketRelation { + kind: ticket::TicketRelationKind::DependsOn, + target: dependency.id.clone(), + note: Some("Partial work is intentional".into()), + author: Some("owner".into()), + }, + ) + .unwrap(); + dependency.id +} + +fn start_http_request( + source: Option<&StartSource>, + token: &str, + method: &str, + path: &str, + body: Value, +) -> Request { + let body = serde_json::to_vec(&body).unwrap(); + match source { + Some(source) => runtime_source_request( + &source.identity, + Some(&source.worker.worker_id), + method, + path, + body, + ), + None => Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body)) + .unwrap(), + } +} + +async fn start_http_response( + api: &WorkspaceApi, + request: Request, + expected: StatusCode, +) -> Value { + let response = build_router(api.clone()).oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body)); + serde_json::from_slice(&body) + .unwrap_or_else(|_| serde_json::json!({"message": String::from_utf8_lossy(&body)})) +} + +fn observed_start_delegation( + api: &WorkspaceApi, + ticket_id: &str, + source: Option<&StartSource>, +) -> Option { + source.map(|_| { + api.store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + ticket_id, + TicketAssignmentRole::Orchestrator, + ) + .unwrap() + .unwrap() + .assignment_id + }) +} + +fn start_worker_body( + ticket_id: Option<&str>, + attachments: Value, + delegation: Option<&str>, +) -> Value { + let mut body = serde_json::json!({ + "runtime_id": WorkdirlessFixtureRuntime::RUNTIME_ID, + "display_name": "Partial investigation", + "profile": "builtin:intake", + "initial_submit": [], + "workdir_attachments": attachments, + "feature_connections": {}, + "control_operation_id": "partial-control-start" + }); + if let Some(ticket_id) = ticket_id { + body["ticket_assignment"] = serde_json::json!({ + "ticket_id": ticket_id, + "operation_id": "partial-start" + }); + if let Some(delegation) = delegation { + body["ticket_assignment"]["orchestrator_assignment_id"] = + Value::String(delegation.into()); + } + } + body +} + +fn assert_start_dependency_diagnostic_only(api: &WorkspaceApi, ticket: &str, dependency: &str) { + let Json(detail) = browser_ticket_detail(api, ticket).unwrap(); + assert_eq!(detail.relations.blockers.len(), 1); + assert_eq!(detail.relations.blockers[0].blocking_ticket, dependency); + assert!(!detail.action_eligibility.can_start_manual_worker); + assert!(detail.current_worker.is_some()); + let Json(prerequisite) = browser_ticket_detail(api, dependency).unwrap(); + assert_eq!(prerequisite.state.as_str(), "open"); + assert!(prerequisite.assignments.is_empty()); + assert!(prerequisite.assignment_tracking.is_empty()); +} + +#[tokio::test] +async fn unfinished_dependencies_allow_manual_and_orchestrator_resource_free_start_and_assign() { + for orchestrator in [false, true] { + for existing in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let mut api = test_api(directory.path()).await; + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let source = if orchestrator { + Some(start_source(&mut api, &runtime).await) + } else { + None + }; + let token = seed_test_api_token(api.store.as_ref(), "partial-start-owner"); + let ticket = browser_ticket_backend(&api) + .unwrap() + .create(ticket::NewTicket::new( + "Partial resource-free investigation", + )) + .unwrap(); + let dependency = add_unfinished_start_dependency(&api, &ticket.id); + if orchestrator { + assign_test_orchestrator(&api, &ticket.id); + } + let delegation = observed_start_delegation(&api, &ticket.id, source.as_ref()); + assert!( + browser_ticket_detail(&api, &ticket.id) + .unwrap() + .0 + .action_eligibility + .can_start_manual_worker + ); + let create_path = if orchestrator { + format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers") + } else { + format!("/api/w/{TEST_WORKSPACE_ID}/workers") + }; + let created = start_http_response( + &api, + start_http_request( + source.as_ref(), + &token, + "POST", + &create_path, + start_worker_body( + (!existing).then_some(ticket.id.as_str()), + serde_json::json!([]), + delegation.as_deref(), + ), + ), + StatusCode::OK, + ) + .await; + let worker_id = created["worker_id"].as_str().unwrap(); + assert_eq!(created["worker"]["profile"], "builtin:intake"); + if existing { + let path = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker", + ticket.id + ); + let mut body = serde_json::json!({ + "operation_id": "partial-existing-assign", + "principal": { + "kind": "worker", + "runtime_id": WorkdirlessFixtureRuntime::RUNTIME_ID, + "worker_id": worker_id + }, + "workdir_bindings": [] + }); + if let Some(delegation) = &delegation { + body["orchestrator_assignment_id"] = Value::String(delegation.clone()); + } + let assigned = start_http_response( + &api, + start_http_request(source.as_ref(), &token, "PUT", &path, body.clone()), + StatusCode::OK, + ) + .await; + let replay = start_http_response( + &api, + start_http_request(source.as_ref(), &token, "PUT", &path, body), + StatusCode::OK, + ) + .await; + assert_eq!(assigned, replay); + } + let worker = RuntimeWorkerRef::new(WorkdirlessFixtureRuntime::RUNTIME_ID, worker_id); + assert!( + api.store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap() + .is_empty() + ); + assert_start_dependency_diagnostic_only(&api, &ticket.id, &dependency); + // Partial work never permits a second unfinished Worker. + let mut competing = start_worker_body( + Some(&ticket.id), + serde_json::json!([]), + delegation.as_deref(), + ); + competing["ticket_assignment"]["operation_id"] = + Value::String("competing-start".into()); + competing["control_operation_id"] = Value::String("competing-control-start".into()); + let spawn_count = runtime.spawn_requests().len(); + start_http_response( + &api, + start_http_request(source.as_ref(), &token, "POST", &create_path, competing), + StatusCode::CONFLICT, + ) + .await; + assert_eq!(runtime.spawn_requests().len(), spawn_count); + if !orchestrator { + assert!( + api.store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket.id, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + } + } + } +} + +#[tokio::test] +async fn unfinished_dependencies_allow_read_only_start_and_assign_without_write_or_command_grants() +{ + for orchestrator in [false, true] { + for existing in [false, true] { + let mut fixture = manual_worker_assignment_fixture().await; + let source = if orchestrator { + Some(start_source(&mut fixture.api, &fixture.runtime).await) + } else { + None + }; + let api = &fixture.api; + // Spawn finalization observes Runtime Workdir identity; do not use + // the adapter's unrelated default Repository id for this checkout. + fixture.runtime.set_workdir_repository( + &fixture.docs_workdir_id, + &test_repository_id_by_key(api, "docs"), + ); + let backend = browser_ticket_backend(api).unwrap(); + // Keep only the reference target. Partial investigation needs no writable Repository. + backend + .edit_item( + fixture.ticket_id.clone().into(), + ticket::TicketItemEdit { + targets: Some(ticket::TicketTargetsEdit::Set { + targets: vec![test_ticket_target( + "docs", + "develop", + TicketTargetAccess::ReadOnly, + )], + }), + ..Default::default() + }, + ) + .unwrap(); + let dependency = add_unfinished_start_dependency(api, &fixture.ticket_id); + if orchestrator { + assign_test_orchestrator(api, &fixture.ticket_id); + } + let delegation = observed_start_delegation(api, &fixture.ticket_id, source.as_ref()); + let token = seed_test_api_token(api.store.as_ref(), "read-only-start-owner"); + let worker = if existing { + // Drop the unrelated checkout so the entire attachment set is reference-only. + let main = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .into_iter() + .find(|link| link.workdir_id == fixture.main_workdir_id) + .unwrap(); + api.store + .detach_worker_workdir_connection( + TEST_WORKSPACE_ID, + &fixture.worker, + &main.alias, + &main.connection_id, + &now_registry_timestamp(), + ) + .unwrap(); + sync_runtime_worker_workdir_attachments(api, &fixture.worker).unwrap(); + let mut body = serde_json::to_value(manual_worker_assignment_request( + &fixture, + "read-only-existing", + )) + .unwrap(); + if let Some(delegation) = &delegation { + body["orchestrator_assignment_id"] = Value::String(delegation.clone()); + } + body["workdir_bindings"] = serde_json::json!([{ + "alias": "docs", "working_directory_id": fixture.docs_workdir_id, + "connection_id": api.store.list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker).unwrap().into_iter().find(|link| link.workdir_id == fixture.docs_workdir_id && link.unlinked_at.is_none()).unwrap().connection_id + }]); + let path = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker", + fixture.ticket_id + ); + start_http_response( + api, + start_http_request(source.as_ref(), &token, "PUT", &path, body), + StatusCode::OK, + ) + .await; + fixture.worker.clone() + } else { + // This matrix tests read-only start, not sharing an occupied + // Workdir. Release the fixture candidate's reference attachment. + let docs = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap() + .into_iter() + .find(|link| { + link.workdir_id == fixture.docs_workdir_id && link.unlinked_at.is_none() + }) + .unwrap(); + api.store + .detach_worker_workdir_connection( + TEST_WORKSPACE_ID, + &fixture.worker, + &docs.alias, + &docs.connection_id, + &now_registry_timestamp(), + ) + .unwrap(); + sync_runtime_worker_workdir_attachments(api, &fixture.worker).unwrap(); + let path = if orchestrator { + format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers") + } else { + format!("/api/w/{TEST_WORKSPACE_ID}/workers") + }; + let body = start_worker_body( + Some(&fixture.ticket_id), + serde_json::json!([{ + "alias": "docs", "working_directory_id": fixture.docs_workdir_id + }]), + delegation.as_deref(), + ); + let created = start_http_response( + api, + start_http_request(source.as_ref(), &token, "POST", &path, body), + StatusCode::OK, + ) + .await; + assert_eq!(created["worker"]["profile"], "builtin:intake"); + RuntimeWorkerRef::new( + WorkdirlessFixtureRuntime::RUNTIME_ID, + created["worker_id"].as_str().unwrap(), + ) + }; + let links = api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &worker) + .unwrap() + .into_iter() + .filter(|link| link.unlinked_at.is_none()) + .collect::>(); + assert_eq!(links.len(), 1); + assert_eq!(links[0].workdir_id, fixture.docs_workdir_id); + assert_eq!( + links[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + if existing { + let attachments = fixture.runtime.logical_attachments(&worker.worker_id); + assert_eq!(attachments.len(), 1); + assert_eq!( + attachments[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + } else { + // This adapter records spawn inputs separately from the + // replacement-attachment calls used by existing-Worker assign. + let request = fixture.runtime.spawn_requests().last().unwrap().clone(); + assert_eq!(request.resolved_workdir_attachments.len(), 1); + assert_eq!( + request.resolved_workdir_attachments[0].working_directory_id, + fixture.docs_workdir_id + ); + assert_eq!( + request.resolved_workdir_attachments[0].capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + } + assert_start_dependency_diagnostic_only(api, &fixture.ticket_id, &dependency); + } + } +} + +#[tokio::test] +async fn unfinished_dependencies_do_not_bypass_http_auth_closed_or_resource_binding_guards() { + let fixture = manual_worker_assignment_fixture().await; + let api = &fixture.api; + add_unfinished_start_dependency(api, &fixture.ticket_id); + let token = seed_test_api_token(api.store.as_ref(), "guard-start-owner"); + let path = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker", + fixture.ticket_id + ); + let valid = + serde_json::to_value(manual_worker_assignment_request(&fixture, "guarded-assign")).unwrap(); + let denied = start_http_response( + api, + start_http_request(None, "invalid-token", "PUT", &path, valid.clone()), + StatusCode::UNAUTHORIZED, + ) + .await; + assert!(denied["message"].is_string()); + let mut stale = valid.clone(); + stale["workdir_bindings"][0]["connection_id"] = Value::String("stale-connection".into()); + let denied = start_http_response( + api, + start_http_request(None, &token, "PUT", &path, stale), + StatusCode::CONFLICT, + ) + .await; + assert!(denied["message"].as_str().unwrap().contains("connection")); + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .unwrap() + .is_none() + ); + // The existing case/work-release trigger is separate from relation diagnostics. + rusqlite::Connection::open(&api.config.database_path).unwrap().execute( + "UPDATE typed_tickets SET workflow_state='closed' WHERE workspace_id=?1 AND ticket_id=?2", + rusqlite::params![TEST_WORKSPACE_ID, fixture.ticket_id], + ).unwrap(); + let denied = start_http_response( + api, + start_http_request(None, &token, "PUT", &path, valid), + StatusCode::CONFLICT, + ) + .await; + assert!( + denied["message"].as_str().unwrap().contains("open") + || denied["message"].as_str().unwrap().contains("closed") + ); + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .unwrap() + .is_none() + ); +} + +#[tokio::test] +async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_delegation() { + for existing in [false, true] { + for invalidation in ["missing", "stale", "clear", "reissue"] { + let directory = tempfile::tempdir().unwrap(); + let mut api = test_api(directory.path()).await; + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let source = start_source(&mut api, &runtime).await; + let token = seed_test_api_token(api.store.as_ref(), "delegation-owner"); + let ticket = browser_ticket_backend(&api) + .unwrap() + .create(ticket::NewTicket::new("Observed delegation start")) + .unwrap(); + assign_test_orchestrator(&api, &ticket.id); + let observed = observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(); + let worker = if existing { + Some( + start_http_response( + &api, + start_http_request( + None, + &token, + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/workers"), + start_worker_body(None, serde_json::json!([]), None), + ), + StatusCode::OK, + ) + .await, + ) + } else { + None + }; + if matches!(invalidation, "clear" | "reissue") { + let clear = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/orchestrator?assignment_id={observed}&operation_id=clear-observed", + ticket.id + ); + start_http_response( + &api, + start_http_request(None, &token, "DELETE", &clear, Value::Null), + StatusCode::OK, + ) + .await; + if invalidation == "reissue" { + let path = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/orchestrator", + ticket.id + ); + start_http_response(&api, start_http_request(None, &token, "PUT", &path, + serde_json::json!({"operation_id":"reissue-observed", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})), StatusCode::OK).await; + assert_ne!( + observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(), + observed + ); + } + } + let submitted = match invalidation { + "missing" => None, + "stale" => Some("unrelated-assignment"), + _ => Some(observed.as_str()), + }; + let (method, path, body) = if let Some(worker) = worker { + let mut body = serde_json::json!({ + "operation_id":"invalid-existing-start", + "principal":{"kind":"worker", "runtime_id":WorkdirlessFixtureRuntime::RUNTIME_ID, "worker_id":worker["worker_id"]}, + "workdir_bindings":[] + }); + if let Some(submitted) = submitted { + body["orchestrator_assignment_id"] = Value::String(submitted.into()); + } + ( + "PUT", + format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker", + ticket.id + ), + body, + ) + } else { + ( + "POST", + format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"), + start_worker_body(Some(&ticket.id), serde_json::json!([]), submitted), + ) + }; + let spawns = runtime.spawn_requests().len(); + let denied = start_http_response( + &api, + start_http_request(Some(&source), &token, method, &path, body), + StatusCode::CONFLICT, + ) + .await; + assert!( + denied["message"].as_str().unwrap().contains("assignment") + || denied["message"].as_str().unwrap().contains("delegation"), + "{invalidation}: {denied}" + ); + assert_eq!(runtime.spawn_requests().len(), spawns); + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + let operation = if existing { + "invalid-existing-start" + } else { + "partial-start" + }; + assert!( + api.store + .get_ticket_assignment_operation(TEST_WORKSPACE_ID, operation) + .unwrap() + .is_none() + ); + } + } +} + +#[tokio::test] +async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_delegation() { + for existing in [false, true] { + let directory = tempfile::tempdir().unwrap(); + let mut api = test_api(directory.path()).await; + register_test_runtime(&api, WorkdirlessFixtureRuntime::RUNTIME_ID).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let source = start_source(&mut api, &runtime).await; + let token = seed_test_api_token(api.store.as_ref(), "obsolete-worker-owner"); + let ticket = browser_ticket_backend(&api) + .unwrap() + .create(ticket::NewTicket::new( + "Retained delegation after Worker replacement", + )) + .unwrap(); + assign_test_orchestrator(&api, &ticket.id); + let delegation = observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(); + let worker = if existing { + Some( + start_http_response( + &api, + start_http_request( + None, + &token, + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/workers"), + start_worker_body(None, serde_json::json!([]), None), + ), + StatusCode::OK, + ) + .await, + ) + } else { + None + }; + // Backend ownership changes independently of the retained assignment row. + let replacement = + RuntimeWorkerRef::new(&source.worker.runtime_id, WorkerId::now_v7().to_string()); + seed_worker_source_member(&api, &replacement.runtime_id, &replacement.worker_id); + let mut summary = runtime.workers.lock().unwrap()[0].clone(); + summary.worker = replacement.clone(); + runtime.workers.lock().unwrap().push(summary); + rusqlite::Connection::open(&api.config.database_path).unwrap().execute( + "UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", + rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY], + ).unwrap(); + assert_eq!( + find_workspace_orchestrator(&api).unwrap().worker, + replacement + ); + assert_eq!( + observed_start_delegation(&api, &ticket.id, Some(&source)).unwrap(), + delegation + ); + let (method, path, body) = if let Some(worker) = worker { + ( + "PUT", + format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker", + ticket.id + ), + serde_json::json!({ + "operation_id":"obsolete-existing-start", "orchestrator_assignment_id":delegation, + "principal":{"kind":"worker", "runtime_id":WorkdirlessFixtureRuntime::RUNTIME_ID, "worker_id":worker["worker_id"]}, + "workdir_bindings":[] + }), + ) + } else { + ( + "POST", + format!("/api/w/{TEST_WORKSPACE_ID}/worker-control/workers"), + start_worker_body(Some(&ticket.id), serde_json::json!([]), Some(&delegation)), + ) + }; + let spawns = runtime.spawn_requests().len(); + let denied = start_http_response( + &api, + start_http_request(Some(&source), &token, method, &path, body), + // The authenticated Worker-source middleware fences the replaced + // singleton before the start/assign handler is admitted. + StatusCode::UNAUTHORIZED, + ) + .await; + assert!(denied["message"].is_string()); + assert_eq!(runtime.spawn_requests().len(), spawns); + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + } +} diff --git a/crates/workspace-server/src/server_assignment_tracking_tests.rs b/crates/workspace-server/src/server_assignment_tracking_tests.rs new file mode 100644 index 000000000..5c315a5d1 --- /dev/null +++ b/crates/workspace-server/src/server_assignment_tracking_tests.rs @@ -0,0 +1,2059 @@ +use super::*; + +#[tokio::test] +async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_coder_start() { + for runtime_source in [true, false] { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let token = seed_test_api_token(api.store.as_ref(), "assign-replay-owner"); + let request = |method: &str, path: &str, body: Vec| { + if runtime_source { + runtime_source_request(&identity, Some(&source.worker_id), method, path, body) + } else { + Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body)) + .unwrap() + } + }; + let app = build_router(api.clone()); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); + let input = |key: &str| { + serde_json::to_vec(&serde_json::json!({ + "operation_id": key, + "principal": {"kind":"workspace_agent","agent_key":"workspace-orchestrator"} + })) + .unwrap() + }; + let saved = tracking_response( + app.clone(), + request("PUT", &path, input("saved-assign")), + StatusCode::OK, + ) + .await; + let assignment = saved["assignment"]["assignment_id"].as_str().unwrap(); + tracking_response( + app.clone(), + request( + "DELETE", + &assignment_clear_path(&ticket, assignment, "clear-before-coder"), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + let Json(candidate) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: source.runtime_id.clone(), + display_name: "Coder candidate".into(), + singleton_key: None, + profile: Some("builtin:coder".into()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + feature_connections: Default::default(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + let worker_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/worker"); + let worker_input = serde_json::to_vec(&serde_json::json!({ + "operation_id":"start-coder", "principal":{"kind":"worker", "runtime_id":candidate.runtime_id, "worker_id":candidate.worker_id} + })).unwrap(); + tracking_response( + app.clone(), + Request::builder() + .method("PUT") + .uri(&worker_path) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(worker_input)) + .unwrap(), + StatusCode::OK, + ) + .await; + let active = api + .store + .get_active_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Worker, + ) + .unwrap() + .unwrap(); + let retained = api + .store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, assignment) + .unwrap() + .unwrap(); + assert!(!retained.current); + let counts = || { + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + [ + "ticket_worker_assignments", + "ticket_current_worker_assignments", + "ticket_worker_assignment_events", + "ticket_assignment_operations", + "typed_ticket_assignment_tracking_history", + "typed_ticket_assignment_tracking_operations", + "ticket_assignment_work_releases", + ] + .map(|table| { + conn.query_row( + &format!("SELECT count(*) FROM {table} WHERE workspace_id=?1"), + [TEST_WORKSPACE_ID], + |row| row.get::<_, i64>(0), + ) + .unwrap() + }) + }; + let before = counts(); + assert_eq!( + tracking_response( + app.clone(), + request("PUT", &path, input("saved-assign")), + StatusCode::OK + ) + .await, + saved + ); + // Receipt replay does not recreate a delegation or transfer manual work. + assert_eq!(counts(), before); + let fresh = tracking_response( + app, + request("PUT", &path, input("new-assign")), + StatusCode::OK, + ) + .await; + assert_ne!( + fresh["assignment"]["assignment_id"].as_str(), + Some(assignment) + ); + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, assignment) + .unwrap() + .unwrap(), + retained + ); + assert_eq!( + api.store + .get_active_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Worker + ) + .unwrap() + .unwrap(), + active + ); + assert_eq!( + api.store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .unwrap() + .assignment_id, + fresh["assignment"]["assignment_id"].as_str().unwrap() + ); + } +} + +fn tracking_path(ticket: &str) -> String { + format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-tracking") +} + +fn assignment_request( + identity: &RuntimeIdentityMaterial, + worker: &str, + ticket: &str, + operation: &str, +) -> Request { + runtime_source_request(identity, Some(worker), "PUT", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), + serde_json::to_vec(&serde_json::json!({"operation_id":operation,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()) +} + +fn assignment_clear_path(ticket: &str, assignment: &str, operation: &str) -> String { + format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator?assignment_id={assignment}&operation_id={operation}" + ) +} + +fn replace_tracking_orchestrator( + api: &WorkspaceApi, + runtime: &WorkdirlessFixtureRuntime, + source: &RuntimeWorkerRef, +) -> RuntimeWorkerRef { + let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string()); + seed_worker_source_member(api, &replacement.runtime_id, &replacement.worker_id); + let mut summary = runtime.workers.lock().unwrap()[0].clone(); + summary.worker = replacement.clone(); + runtime.workers.lock().unwrap().push(summary); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap(); + assert_eq!( + find_workspace_orchestrator(api).unwrap().worker, + replacement + ); + replacement +} + +#[tokio::test] +async fn revoked_runtime_cannot_retry_assign_clear_or_record_tracking() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let app = build_router(api.clone()); + let clear_path = + assignment_clear_path(&ticket, ¤t.assignment_id, "clear-before-revocation"); + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &clear_path, + Vec::new(), + ), + StatusCode::OK, + ) + .await; + let released = api + .store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE workspace_runtime_bindings SET revoked_at='2026-10-11T00:00:00Z' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap(); + for request in [ + assignment_request(&identity, &source.worker_id, &ticket, "assign-tracking"), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &clear_path, + Vec::new(), + ), + signed_tracking_update( + &identity, + Some(&source.worker_id), + &ticket, + &tracking_update(¤t, "revoked-report"), + ), + ] { + tracking_response(app.clone(), request, StatusCode::UNAUTHORIZED).await; + } + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + released + ); +} + +#[tokio::test] +async fn replaced_worker_cannot_retry_assign_clear_and_new_owner_cannot_reuse_old_receipts() { + let dir = tempfile::tempdir().unwrap(); + let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let app = build_router(api.clone()); + let clear_path = + assignment_clear_path(&ticket, ¤t.assignment_id, "clear-before-replacement"); + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &clear_path, + Vec::new(), + ), + StatusCode::OK, + ) + .await; + let replacement = replace_tracking_orchestrator(&api, &runtime, &source); + for worker in [&source.worker_id, &replacement.worker_id] { + let expected = if worker == &source.worker_id { + StatusCode::UNAUTHORIZED + } else { + StatusCode::CONFLICT + }; + tracking_response( + app.clone(), + assignment_request(&identity, worker, &ticket, "assign-tracking"), + expected, + ) + .await; + tracking_response( + app.clone(), + runtime_source_request(&identity, Some(worker), "DELETE", &clear_path, Vec::new()), + expected, + ) + .await; + } + assert!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap() + .is_empty() + ); + tracking_response( + app, + assignment_request( + &identity, + &replacement.worker_id, + &ticket, + "replacement-new-request", + ), + StatusCode::OK, + ) + .await; + let next = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(); + assert_eq!(next.len(), 1); + assert_ne!(next[0].assignment_id, current.assignment_id); +} + +#[tokio::test] +async fn runtime_orchestrator_tags_without_backend_singleton_do_not_grant_assignment_authority() { + let dir = tempfile::tempdir().unwrap(); + let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let forged = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string()); + seed_worker_source_member(&api, &forged.runtime_id, &forged.worker_id); + let mut summary = runtime.workers.lock().unwrap()[0].clone(); + summary.worker = forged.clone(); + summary.singleton_key = Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()); + runtime.workers.lock().unwrap().push(summary); + let app = build_router(api.clone()); + for request in [ + assignment_request(&identity, &forged.worker_id, &ticket, "forged-assign"), + runtime_source_request( + &identity, + Some(&forged.worker_id), + "DELETE", + &assignment_clear_path(&ticket, ¤t.assignment_id, "forged-clear"), + Vec::new(), + ), + signed_tracking_update( + &identity, + Some(&forged.worker_id), + &ticket, + &tracking_update(¤t, "forged-tracking"), + ), + ] { + tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await; + } + assert_eq!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(), + vec![current] + ); +} + +#[tokio::test] +async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reject_other_accounts() +{ + let dir = tempfile::tempdir().unwrap(); + let (api, _, _, _, ticket) = tracking_fixture(dir.path()).await; + let owner = seed_test_api_token(api.store.as_ref(), "assignment-account-owner"); + let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other"); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); + let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap(); + let request = |token: &str, method: &str, path: &str, body: Vec| { + Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body)) + .unwrap() + }; + let app = build_router(api.clone()); + let assigned = tracking_response( + app.clone(), + request(&owner, "PUT", &path, body.clone()), + StatusCode::OK, + ) + .await; + let actor = format!( + "workspace-user:{}", + serde_json::to_string(&( + "account-assignment-account-owner", + "user-assignment-account-owner" + )) + .unwrap() + ); + assert_eq!(assigned["assignment"]["assigned_by"], actor); + assert_eq!( + tracking_response( + app.clone(), + request(&owner, "PUT", &path, body.clone()), + StatusCode::OK + ) + .await, + assigned + ); + tracking_response( + app.clone(), + request(&other, "PUT", &path, body), + StatusCode::CONFLICT, + ) + .await; + let assignment_id = assigned["assignment"]["assignment_id"].as_str().unwrap(); + let clear_path = assignment_clear_path(&ticket, assignment_id, "account-clear"); + let cleared = tracking_response( + app.clone(), + request(&owner, "DELETE", &clear_path, Vec::new()), + StatusCode::OK, + ) + .await; + assert_eq!( + tracking_response( + app.clone(), + request(&owner, "DELETE", &clear_path, Vec::new()), + StatusCode::OK + ) + .await, + cleared + ); + tracking_response( + app, + request(&other, "DELETE", &clear_path, Vec::new()), + StatusCode::CONFLICT, + ) + .await; + let released = api + .store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, assignment_id) + .unwrap() + .unwrap(); + assert!(!released.current); + assert_eq!(released.assigned_by, actor); + assert_eq!(released.release.unwrap().actor, actor); +} + +async fn assign_tracking( + api: &WorkspaceApi, + identity: &RuntimeIdentityMaterial, + source: &RuntimeWorkerRef, + ticket: &str, +) -> ticket::TicketAssignmentTrackingProjection { + let value = tracking_response(build_router(api.clone()), runtime_source_request( + identity, Some(&source.worker_id), "PUT", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), + serde_json::to_vec(&serde_json::json!({ "operation_id": "assign-tracking", "principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" } })).unwrap(), + ), StatusCode::OK).await; + assert_eq!( + value["assignment"]["assigned_by"], + format!("worker:{}/{}", source.runtime_id, source.worker_id) + ); + let current = tracking_response( + build_router(api.clone()), + runtime_source_request( + identity, + Some(&source.worker_id), + "GET", + &tracking_path(ticket), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(current.as_array().unwrap().len(), 1); + serde_json::from_value(current[0].clone()).unwrap() +} + +fn tracking_update( + current: &ticket::TicketAssignmentTrackingProjection, + key: &str, +) -> ticket::TicketAssignmentTrackingUpdate { + ticket::TicketAssignmentTrackingUpdate { + assignment_id: current.assignment_id.clone(), + expected_last_event_id: current.last_event_id.clone().unwrap(), + operation_key: key.into(), + tracking: ticket::TicketAssignmentTracking { + status: ticket::TicketAssignmentTrackingStatus::Done, + waiting_reason: None, + progress: Some("Investigated".into()), + next_action: Some("Return to human".into()), + result: Some("Result recorded without closing the case".into()), + }, + } +} + +fn signed_tracking_update( + identity: &RuntimeIdentityMaterial, + worker: Option<&str>, + ticket: &str, + update: &ticket::TicketAssignmentTrackingUpdate, +) -> Request { + runtime_source_request( + identity, + worker, + "POST", + &tracking_path(ticket), + serde_json::to_vec(update).unwrap(), + ) +} + +#[tokio::test] +async fn signed_current_orchestrator_without_hints_records_done_without_closing_or_releasing_work() +{ + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + assert_eq!( + current.tracking.as_ref().unwrap().status, + ticket::TicketAssignmentTrackingStatus::Waiting + ); + let before = browser_ticket_backend(&api) + .unwrap() + .show(ticket.clone().into()) + .unwrap(); + let update = tracking_update(¤t, "report-done"); + let response = tracking_response( + build_router(api.clone()), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + assert_eq!(response["tracking"]["status"], "done"); + assert_ne!(response["last_event_id"], current.last_event_id.unwrap()); + assert_eq!( + browser_ticket_backend(&api) + .unwrap() + .show(ticket.clone().into()) + .unwrap(), + before + ); + assert!( + api.store + .get_active_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_some() + ); + let detail = api.authority.ticket(&ticket).unwrap(); + assert_eq!( + detail.assignment_tracking[0] + .tracking + .as_ref() + .unwrap() + .status, + ticket::TicketAssignmentTrackingStatus::Done + ); + let history = tracking_response( + build_router(api.clone()), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{}/history?assignment_id={}", + tracking_path(&ticket), + update.assignment_id + ), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + let event = history["items"].as_array().unwrap().last().unwrap(); + assert_eq!(event["source_context"]["runtime_id"], source.runtime_id); + assert_eq!(event["source_context"]["worker_id"], source.worker_id); + assert_eq!( + event["source_context"]["authentication_binding_id"], + api.config_store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, &source.runtime_id) + .unwrap() + .unwrap() + .binding_id + ); +} + +#[tokio::test] +async fn tracking_runtime_only_and_non_orchestrator_and_forged_source_hints_are_denied() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + seed_worker_source_member(&api, &source.runtime_id, "other"); + let update = tracking_update(¤t, "unauthorized"); + let app = build_router(api.clone()); + for worker in [None, Some("other")] { + tracking_response( + app.clone(), + signed_tracking_update(&identity, worker, &ticket, &update), + StatusCode::FORBIDDEN, + ) + .await; + } + for (runtime_hint, worker_hint) in [ + (Some(source.runtime_id.as_str()), Some("other")), + (Some("another-runtime"), Some(source.worker_id.as_str())), + (None, Some(source.worker_id.as_str())), + ] { + let mut request = + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update); + if let Some(hint) = runtime_hint { + request + .headers_mut() + .insert("x-yoi-runtime-id", hint.parse().unwrap()); + } + if let Some(hint) = worker_hint { + request + .headers_mut() + .insert("x-yoi-worker-id", hint.parse().unwrap()); + } + tracking_response(app.clone(), request, StatusCode::FORBIDDEN).await; + } + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + current + ); +} + +#[tokio::test] +async fn tracking_closed_ticket_current_orchestrator_can_record_result_without_restoring_grants() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let backend = browser_ticket_backend(&api).unwrap(); + let before = backend.show(ticket.clone().into()).unwrap(); + let close = case_test_request( + &before, + "close-tracking", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + tracking_response( + build_router(api.clone()), + runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/case"), + serde_json::to_vec(&serde_json::json!({ + "expected_content_digest": close.expected_content_digest, + "expected_state": close.expected_state, + "expected_closure_operation_key": close.expected_closure_operation_key, + "operation_key": close.operation_key, + "action": close.action, + "explanation": close.explanation, + "references": close.references, + })) + .unwrap(), + ), + StatusCode::OK, + ) + .await; + let closed = backend.show(ticket.clone().into()).unwrap(); + assert_eq!(closed.meta.workflow_state, TicketState::Closed); + assert!( + api.store + .get_active_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + let update = tracking_update(¤t, "post-close-result"); + tracking_response( + build_router(api.clone()), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + assert_eq!(backend.show(ticket.clone().into()).unwrap(), closed); + assert!( + api.store + .get_active_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + assert_eq!( + api.store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .unwrap() + .assignment_id, + current.assignment_id + ); +} + +async fn tracking_response( + app: Router, + request: Request, + expected: StatusCode, +) -> serde_json::Value { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body)); + if body.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&body).unwrap_or_else(|_| { + assert_eq!( + status, + StatusCode::UNPROCESSABLE_ENTITY, + "non-JSON API response: {}", + String::from_utf8_lossy(&body) + ); + serde_json::Value::String(String::from_utf8(body.to_vec()).unwrap()) + }) + } +} + +async fn tracking_fixture( + root: &Path, +) -> ( + WorkspaceApi, + WorkdirlessFixtureRuntime, + RuntimeIdentityMaterial, + RuntimeWorkerRef, + String, +) { + let mut api = test_api(root).await; + let runtime_id = WorkdirlessFixtureRuntime::RUNTIME_ID; + register_test_runtime(&api, runtime_id).await; + let runtime = WorkdirlessFixtureRuntime::default(); + api.runtime.register_or_replace(runtime.clone()); + let identity = RuntimeIdentityMaterial::generate(runtime_id).unwrap(); + configure_runtime_request_auth(&mut api, &identity, runtime_id); + let memory = api + .config_store + .get_workspace_memory_settings(TEST_WORKSPACE_ID) + .unwrap(); + let reservation = api + .config_store + .reserve_worker_create( + TEST_WORKSPACE_ID, + runtime_id, + "tracking-orchestrator", + "sha256:tracking-orchestrator", + Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY), + &memory, + ) + .unwrap(); + let mut summary = WorkdirlessFixtureRuntime::worker_summary( + WorkerCreateBinding { + worker_id: reservation.worker_id, + create_fingerprint: reservation.create_fingerprint, + }, + &WorkerSpawnRequest { + requested_worker_name: Some("Tracking Orchestrator".into()), + singleton_key: Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY.into()), + intent: WorkerSpawnIntent::WorkspaceCompanion, + acceptance: WorkerSpawnAcceptanceRequirement::RunAccepted { + expected_segments: 0, + }, + profile: ProfileSelector::Builtin("builtin:orchestrator".into()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachment_requests: Vec::new(), + resolved_workdir_attachment_requests: Vec::new(), + resolved_workdir_attachments: Vec::new(), + resolved_config_bundle: None, + resolved_worker_observation_enabled: false, + resolved_worker_observation_grants: Vec::new(), + resolved_workspace_api: None, + resolved_memory_settings: None, + resolved_subjektiv_attached: false, + resolved_control_operation: None, + }, + ); + summary.tags = vec!["orchestrator".into(), "singleton".into()]; + let source = summary.worker.clone(); + runtime.workers.lock().unwrap().push(summary); + seed_worker_source_member(&api, runtime_id, &source.worker_id); + api.config_store + .complete_worker_create_reservation(TEST_WORKSPACE_ID, source.worker_id.parse().unwrap()) + .unwrap(); + assert_eq!(find_workspace_orchestrator(&api).unwrap().worker, source); + let ticket = browser_ticket_backend(&api) + .unwrap() + .create(ticket::NewTicket::new("Tracking authority")) + .unwrap(); + (api, runtime, identity, source, ticket.id) +} + +#[tokio::test] +async fn tracking_exact_replay_is_source_bound_and_stale_event_cas_is_rejected() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "replay-key"); + let app = build_router(api.clone()); + let first_request = + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update); + let proof = + first_request.headers()[worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER].clone(); + let result = tracking_response(app.clone(), first_request, StatusCode::OK).await; + let mut same_proof = + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update); + same_proof.headers_mut().insert( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ); + tracking_response(app.clone(), same_proof, StatusCode::UNAUTHORIZED).await; + assert_eq!( + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK + ) + .await, + result + ); + let mut changed = update.clone(); + changed.tracking.result = Some("Changed result".into()); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &changed), + StatusCode::CONFLICT, + ) + .await; + let stale = tracking_update(¤t, "stale-last-event"); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &stale), + StatusCode::CONFLICT, + ) + .await; + seed_worker_source_member(&api, &source.runtime_id, "other"); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some("other"), &ticket, &update), + StatusCode::FORBIDDEN, + ) + .await; + let history = api + .store + .list_ticket_assignment_tracking_history(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap(); + assert_eq!( + history.len(), + 2, + "Assign plus one committed tracking update only" + ); + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap() + .tracking + .unwrap(), + update.tracking + ); +} + +#[tokio::test] +async fn tracking_unassign_and_reassign_keep_results_and_fence_old_assignment() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "before-reassign"); + let app = build_router(api.clone()); + let result = tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "DELETE", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator?assignment_id={}&operation_id=release-tracking", current.assignment_id), Vec::new()), StatusCode::OK).await; + let released = api + .store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(); + assert!(!released.current); + assert_eq!(released.tracking.unwrap(), update.tracking); + let mut stale = update.clone(); + stale.operation_key = "released-update".into(); + stale.expected_last_event_id = result["last_event_id"].as_str().unwrap().into(); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &stale), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK + ) + .await, + result + ); + tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"assign-again", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await; + let next = api + .store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(); + assert_ne!(next[0].assignment_id, current.assignment_id); + assert_eq!( + next[0].tracking.as_ref().unwrap().status, + ticket::TicketAssignmentTrackingStatus::Waiting + ); + tracking_response( + app, + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &stale), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + api.store + .list_ticket_assignment_tracking_history( + TEST_WORKSPACE_ID, + &ticket, + ¤t.assignment_id + ) + .unwrap() + .len(), + 2 + ); +} + +#[tokio::test] +async fn tracking_other_workspace_routes_and_unsigned_reads_are_rejected() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "foreign-workspace"); + let app = build_router(api.clone()); + tracking_response( + app.clone(), + Request::builder() + .uri(tracking_path(&ticket)) + .body(Body::empty()) + .unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + let path = format!("/workspaces/another-workspace/tickets/{ticket}/assignment-tracking"); + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &path, + serde_json::to_vec(&update).unwrap(), + ), + StatusCode::NOT_FOUND, + ) + .await; + for path in [ + path.clone(), + format!("{path}/history?assignment_id={}", current.assignment_id), + ] { + tracking_response( + app.clone(), + runtime_source_request(&identity, Some(&source.worker_id), "GET", &path, Vec::new()), + StatusCode::NOT_FOUND, + ) + .await; + } + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + current + ); +} + +#[tokio::test] +async fn replaced_orchestrator_cannot_update_or_replay_and_current_replacement_inherits_tracking() { + let dir = tempfile::tempdir().unwrap(); + let (api, runtime, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "old-orchestrator-result"); + let app = build_router(api.clone()); + let result = tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + let replacement = RuntimeWorkerRef::new(&source.runtime_id, WorkerId::now_v7().to_string()); + seed_worker_source_member(&api, &replacement.runtime_id, &replacement.worker_id); + let mut summary = runtime.workers.lock().unwrap()[0].clone(); + summary.worker = replacement.clone(); + runtime.workers.lock().unwrap().push(summary); + // Seed the authoritative replacement, not merely a Runtime tag or model role. + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE worker_singleton_owners SET worker_id=?1,generation=generation+1 WHERE workspace_id=?2 AND singleton_key=?3", rusqlite::params![replacement.worker_id, TEST_WORKSPACE_ID, crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY]).unwrap(); + assert_eq!( + find_workspace_orchestrator(&api).unwrap().worker, + replacement + ); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::UNAUTHORIZED, + ) + .await; + let mut new_update = update.clone(); + new_update.operation_key = "new-orchestrator-result".into(); + new_update.expected_last_event_id = result["last_event_id"].as_str().unwrap().into(); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &new_update), + StatusCode::UNAUTHORIZED, + ) + .await; + // A different authenticated current actor cannot reuse the old actor's receipt. + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&replacement.worker_id), &ticket, &update), + StatusCode::CONFLICT, + ) + .await; + let inherited = tracking_response( + app, + signed_tracking_update( + &identity, + Some(&replacement.worker_id), + &ticket, + &new_update, + ), + StatusCode::OK, + ) + .await; + assert_eq!(inherited["assignment_id"], current.assignment_id); + assert_eq!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap() + .len(), + 1 + ); +} + +#[tokio::test] +async fn tracking_changed_runtime_binding_cannot_replay_original_receipt() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "binding-replay"); + let app = build_router(api.clone()); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE workspace_runtime_bindings SET binding_id='replacement-binding' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap(); + tracking_response( + app, + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + api.store + .list_ticket_assignment_tracking_history( + TEST_WORKSPACE_ID, + &ticket, + ¤t.assignment_id + ) + .unwrap() + .len(), + 2 + ); +} + +#[tokio::test] +async fn assignment_retry_is_idempotent_but_another_authenticated_actor_cannot_replay_it() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let replay = assign_tracking(&api, &identity, &source, &ticket).await; + assert_eq!(replay, current); + let token = seed_test_api_token(api.store.as_ref(), "assignment-replay-user"); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); + let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap(); + tracking_response( + build_router(api.clone()), + Request::builder() + .method("PUT") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(body)) + .unwrap(), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(), + vec![current] + ); +} + +#[tokio::test] +async fn tracking_history_is_assignment_scoped_and_paged_and_rejects_unknown_cursors() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "history-done"); + let app = build_router(api.clone()); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + let path = format!( + "{}/history?assignment_id={}&limit=1", + tracking_path(&ticket), + current.assignment_id + ); + let first = tracking_response( + app.clone(), + runtime_source_request(&identity, Some(&source.worker_id), "GET", &path, Vec::new()), + StatusCode::OK, + ) + .await; + assert_eq!(first["items"].as_array().unwrap().len(), 1); + assert_eq!(first["items"][0]["tracking"]["status"], "waiting"); + let second = tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{path}&after_event_id={}", + first["next_event_id"].as_str().unwrap() + ), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(second["items"].as_array().unwrap().len(), 1); + assert_eq!(second["items"][0]["tracking"]["status"], "done"); + assert!(second["next_event_id"].is_null()); + for suffix in [ + "&after_event_id=another-assignment-event", + "&limit=0", + "&limit=101", + ] { + let path = if suffix.starts_with("&limit=") { + format!( + "{}/history?assignment_id={}{}", + tracking_path(&ticket), + current.assignment_id, + suffix + ) + } else { + format!("{path}{suffix}") + }; + tracking_response( + app.clone(), + runtime_source_request(&identity, Some(&source.worker_id), "GET", &path, Vec::new()), + StatusCode::BAD_REQUEST, + ) + .await; + } +} + +#[tokio::test] +async fn tracking_model_authorship_and_role_fields_are_rejected_and_human_is_not_self_reporter() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "claimed-role"); + let app = build_router(api.clone()); + for (key, value) in [ + ("author", serde_json::json!("orchestrator")), + ("role", serde_json::json!("orchestrator")), + ( + "source_context", + serde_json::json!({"worker_id":source.worker_id,"runtime_id":source.runtime_id}), + ), + ] { + let mut input = serde_json::to_value(&update).unwrap(); + input[key] = value; + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &tracking_path(&ticket), + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + } + let token = seed_test_api_token(api.store.as_ref(), "human-not-tracking-agent"); + tracking_response( + app, + Request::builder() + .method("POST") + .uri(tracking_path(&ticket)) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(serde_json::to_vec(&update).unwrap())) + .unwrap(), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + current + ); +} + +#[tokio::test] +async fn assignment_receipt_cannot_be_replayed_under_a_new_runtime_binding() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-assignment-binding' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap(); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); + let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap(); + tracking_response( + build_router(api.clone()), + runtime_source_request(&identity, Some(&source.worker_id), "PUT", &path, body), + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap(), + vec![current] + ); +} + +#[tokio::test] +async fn assignment_clear_receipt_binds_verified_actor_and_runtime_binding() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let path = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator?assignment_id={}&operation_id=source-bound-clear", + current.assignment_id + ); + let app = build_router(api.clone()); + let result = tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &path, + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!( + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &path, + Vec::new() + ), + StatusCode::OK + ) + .await, + result + ); + let token = seed_test_api_token(api.store.as_ref(), "another-clear-actor"); + tracking_response( + app.clone(), + Request::builder() + .method("DELETE") + .uri(&path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + StatusCode::CONFLICT, + ) + .await; + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-clear-binding' WHERE workspace_id=?1 AND runtime_id=?2", rusqlite::params![TEST_WORKSPACE_ID, source.runtime_id]).unwrap(); + tracking_response( + app, + runtime_source_request( + &identity, + Some(&source.worker_id), + "DELETE", + &path, + Vec::new(), + ), + StatusCode::CONFLICT, + ) + .await; + assert!( + api.store + .list_current_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket) + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn released_assignment_query_retains_principal_assign_audit_and_last_tracking_without_becoming_current() + { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let app = build_router(api.clone()); + tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "DELETE", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator?assignment_id={}&operation_id=release-snapshot", current.assignment_id), Vec::new()), StatusCode::OK).await; + let values = tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{}?assignment_id={}", + tracking_path(&ticket), + current.assignment_id + ), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(values.as_array().unwrap().len(), 1); + let released = &values[0]; + assert_eq!(released["assignment_id"], current.assignment_id); + assert_eq!(released["role"], "orchestrator"); + assert_eq!( + released["principal"], + serde_json::json!({"kind":"workspace_agent","agent_key":"workspace-orchestrator"}) + ); + assert_eq!(released["assigned_by"], current.assigned_by); + assert_eq!(released["assigned_at"], current.assigned_at); + assert_eq!(released["current"], false); + assert_eq!(released["tracking"]["status"], "waiting"); + assert_eq!(released["release"]["action"], "unassigned"); + assert_eq!(released["release"]["operation_key"], "release-snapshot"); + assert_eq!(released["release"]["actor"], current.assigned_by); + assert_eq!(released["release"]["last_tracking"]["status"], "waiting"); + assert!(released["release"]["replacement_assignment_id"].is_null()); + assert_eq!( + tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &tracking_path(&ticket), + Vec::new() + ), + StatusCode::OK + ) + .await, + serde_json::json!([]) + ); + let other_ticket = browser_ticket_backend(&api) + .unwrap() + .create(ticket::NewTicket::new("Different Ticket")) + .unwrap(); + assert_eq!( + tracking_response( + app, + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{}?assignment_id={}", + tracking_path(&other_ticket.id), + current.assignment_id + ), + Vec::new() + ), + StatusCode::OK + ) + .await, + serde_json::json!([]) + ); +} + +#[tokio::test] +async fn same_principal_reassignment_retains_old_result_and_projects_replacement_identity() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(dir.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let update = tracking_update(¤t, "done-before-new-request"); + let app = build_router(api.clone()); + tracking_response( + app.clone(), + signed_tracking_update(&identity, Some(&source.worker_id), &ticket, &update), + StatusCode::OK, + ) + .await; + let assignment = tracking_response(app.clone(), runtime_source_request(&identity, Some(&source.worker_id), "PUT", &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), serde_json::to_vec(&serde_json::json!({"operation_id":"request-again", "expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()), StatusCode::OK).await; + let next_id = assignment["assignment"]["assignment_id"].as_str().unwrap(); + assert_ne!(next_id, current.assignment_id); + let old = tracking_response( + app.clone(), + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &format!( + "{}?assignment_id={}", + tracking_path(&ticket), + current.assignment_id + ), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(old[0]["current"], false); + assert_eq!(old[0]["tracking"]["status"], "done"); + assert_eq!( + old[0]["tracking"]["result"], + update.tracking.result.unwrap() + ); + assert_eq!(old[0]["release"]["action"], "replaced"); + assert_eq!(old[0]["release"]["replacement_assignment_id"], next_id); + assert_eq!(old[0]["release"]["operation_key"], "request-again"); + let next = tracking_response( + app, + runtime_source_request( + &identity, + Some(&source.worker_id), + "GET", + &tracking_path(&ticket), + Vec::new(), + ), + StatusCode::OK, + ) + .await; + assert_eq!(next.as_array().unwrap().len(), 1); + assert_eq!(next[0]["assignment_id"], next_id); + assert_eq!(next[0]["tracking"]["status"], "waiting"); + assert!(next[0]["tracking"]["result"].is_null()); + assert!(next[0]["release"].is_null()); +} + +#[tokio::test] +async fn assignment_signed_runtime_without_worker_cannot_self_assign_service_role() { + let dir = tempfile::tempdir().unwrap(); + let (api, _, identity, _, ticket) = tracking_fixture(dir.path()).await; + let input = serde_json::json!({ + "operation_id": "runtime-only-assign", + "principal": { "kind": "workspace_agent", "agent_key": "workspace-orchestrator" } + }); + tracking_response( + build_router(api.clone()), + runtime_source_request( + &identity, + None, + "PUT", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"), + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::FORBIDDEN, + ) + .await; + assert!( + api.store + .list_current_ticket_role_assignments(TEST_WORKSPACE_ID, &ticket) + .unwrap() + .is_empty() + ); +} + +#[tokio::test] +async fn public_assignment_answer_authenticates_source_replays_saved_result_and_rejects_read_reassign_race() + { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let token = seed_test_api_token(api.store.as_ref(), "answer-owner"); + let app = build_router(api.clone()); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer"); + let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"answer-op","body":"Choose the bounded option"}); + let account_request = |body: &serde_json::Value| { + Request::builder() + .method("POST") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .header("authorization", format!("Bearer {token}")) + .body(Body::from(serde_json::to_vec(body).unwrap())) + .unwrap() + }; + tracking_response( + app.clone(), + Request::builder() + .method("POST") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + let saved = tracking_response(app.clone(), account_request(&body), StatusCode::OK).await; + assert_eq!(saved["assignment_id"], current.assignment_id); + let backend = browser_ticket_backend(&api).unwrap(); + let authored = backend.show(ticket.clone().into()).unwrap(); + assert_eq!( + authored + .events + .iter() + .filter(|e| e.kind.as_str() == "assignment_answer") + .count(), + 1 + ); + let event = authored + .events + .iter() + .find(|e| e.kind.as_str() == "assignment_answer") + .unwrap(); + assert!( + event + .author + .as_deref() + .unwrap() + .starts_with("workspace-user:") + ); + assert_eq!(event.body.as_str(), "Choose the bounded option"); + assert_eq!( + api.store + .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, ¤t.assignment_id) + .unwrap() + .unwrap(), + current + ); + assert_eq!( + tracking_response(app.clone(), account_request(&body), StatusCode::OK).await, + saved + ); + let mut changed = body.clone(); + changed["body"] = "Changed payload".into(); + tracking_response(app.clone(), account_request(&changed), StatusCode::CONFLICT).await; + let mut spoof = body.clone(); + spoof["actor"] = "owner".into(); + tracking_response( + app.clone(), + account_request(&spoof), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + let assignment_path = + format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator"); + tracking_response(app.clone(),runtime_source_request(&identity,Some(&source.worker_id),"PUT",&assignment_path,serde_json::to_vec(&serde_json::json!({"operation_id":"replace-after-read","expected_assignment_id":current.assignment_id,"principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap()),StatusCode::OK).await; + let mut stale = body.clone(); + stale["operation_key"] = "fresh-stale-answer".into(); + tracking_response(app.clone(), account_request(&stale), StatusCode::CONFLICT).await; + assert_eq!( + tracking_response(app, account_request(&body), StatusCode::OK).await, + saved + ); + assert_eq!( + backend + .show(ticket.into()) + .unwrap() + .events + .iter() + .filter(|e| e.kind.as_str() == "assignment_answer") + .count(), + 1 + ); +} + +#[tokio::test] +async fn public_assignment_pages_and_diagnostics_require_scoped_read_and_reject_foreign_cursors() { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let token = seed_test_api_token(api.store.as_ref(), "diagnostics-reader"); + let app = build_router(api.clone()); + for prefix in [ + format!("/api/w/{TEST_WORKSPACE_ID}"), + format!("/workspaces/{TEST_WORKSPACE_ID}"), + ] { + let path = format!("{prefix}/tickets/{ticket}/assignment-tracking/assignments?limit=1"); + tracking_response( + app.clone(), + Request::builder().uri(&path).body(Body::empty()).unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + let request = |path: String| { + Request::builder() + .uri(path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap() + }; + let page = tracking_response(app.clone(), request(path), StatusCode::OK).await; + assert_eq!(page["items"][0]["assignment_id"], current.assignment_id); + assert!(page["next_assignment_id"].is_null()); + let path = format!( + "{prefix}/tickets/{ticket}/assignment-diagnostics?assignment_id={}&limit=1", + current.assignment_id + ); + let diagnostic = + tracking_response(app.clone(), request(path.clone()), StatusCode::OK).await; + assert_eq!(diagnostic["assignment_id"], current.assignment_id); + assert_eq!(diagnostic["input_origin_known"], true); + assert!(diagnostic.get("payload_json").is_none()); + tracking_response( + app.clone(), + request(format!("{path}&after_input_event_id=ticket-event:999")), + StatusCode::BAD_REQUEST, + ) + .await; + tracking_response(app.clone(),request(format!("{prefix}/tickets/{ticket}/assignment-tracking/assignments?after_assignment_id=foreign")),StatusCode::BAD_REQUEST).await; + tracking_response( + app.clone(), + request(format!( + "{prefix}/tickets/{ticket}/assignment-diagnostics?assignment_id={}&limit=101", + current.assignment_id + )), + StatusCode::BAD_REQUEST, + ) + .await; + } + let path = format!( + "/api/w/another-workspace/tickets/{ticket}/assignment-diagnostics?assignment_id={}", + current.assignment_id + ); + let response = app + .oneshot( + Request::builder() + .uri(path) + .header("authorization", format!("Bearer {token}")) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert!(!response.status().is_success()); +} + +#[tokio::test] +async fn ticket_query_principal_and_handling_filter_before_single_item_page_without_worker_inference() + { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + tracking_response( + build_router(api.clone()), + signed_tracking_update( + &identity, + Some(&source.worker_id), + &ticket, + &tracking_update(¤t, "query-done"), + ), + StatusCode::OK, + ) + .await; + let backend = browser_ticket_backend(&api).unwrap(); + for n in 0..8 { + backend + .create(ticket::NewTicket::new(format!("Unassigned newer {n}"))) + .unwrap(); + } + let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"done","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap(); + let found = api.authority.query_tickets(query).unwrap(); + assert_eq!(found.items.len(), 1); + assert_eq!(found.items[0].id, ticket); + let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"handling_status":"waiting","assigned_principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"},"limit":1})).unwrap(); + assert!(api.authority.query_tickets(query).unwrap().items.is_empty()); + let query:server_api::TicketQueryRequest=serde_json::from_value(serde_json::json!({"assigned_principal":{"kind":"worker","runtime_id":source.runtime_id,"worker_id":source.worker_id},"limit":1})).unwrap(); + assert!(api.authority.query_tickets(query).unwrap().items.is_empty()); +} + +#[test] +fn canonical_assignment_operations_are_explicit_scoped_api_routes_not_static_shell_paths() { + for suffix in [ + "case", + "assignment-tracking", + "assignment-tracking/history", + "assignment-tracking/assignments", + "assignment-diagnostics", + "assignment-answer", + ] { + let path = format!("/workspaces/workspace-a/tickets/ticket-a/{suffix}"); + assert_eq!( + canonical_ticket_operation_workspace_id(&path), + Some("workspace-a") + ); + assert_eq!(scoped_workspace_id(&path), Some("workspace-a")); + assert!(!is_server_static_forward(&path)); + assert!(!is_server_global_forward(&path)); + assert!(canonical_ticket_operation_workspace_id(&format!("{path}/extra")).is_none()); + } + for invalid in [ + "/workspaces//tickets/t/assignment-answer", + "/workspaces/w/tickets//assignment-diagnostics", + "/workspaces/w/tickets/t", + "/workspaces/w/tickets/t/unknown", + "/workspaces/w/tickets/t/assignment-tracking/unknown", + "/workspaces/w/workers/t/assignment-answer", + ] { + assert!( + canonical_ticket_operation_workspace_id(invalid).is_none(), + "{invalid}" + ); + } +} + +#[tokio::test] +async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_browser_and_canonical_paths() + { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + seed_test_api_token(api.store.as_ref(), "assignment-cookie"); + api.store + .create_browser_session(&BrowserSessionRecord { + token_hash: crate::auth::token_hash("assignment-browser-session"), + session_id: "assignment-session".into(), + user_id: "user-assignment-cookie".into(), + created_at: "2026-01-01T00:00:00Z".into(), + expires_at: "2099-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + let AuthConfig::Passkey { + origin, + cookie_name, + .. + } = &api.config.auth; + let cookie = format!("{cookie_name}=assignment-browser-session"); + let catalog = WorkspaceServerApi::new(api.config.clone(), api.store.clone()); + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(catalog); + let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"cookie-answer","body":"Authorized browser continuation"}); + let mut receipt = None; + for prefix in [ + format!("/api/w/{TEST_WORKSPACE_ID}"), + format!("/workspaces/{TEST_WORKSPACE_ID}"), + ] { + let identity_path = + format!("{prefix}/tickets/{ticket}/assignment-tracking/assignments?limit=1"); + let diagnostic_path = format!( + "{prefix}/tickets/{ticket}/assignment-diagnostics?assignment_id={}", + current.assignment_id + ); + for path in [identity_path, diagnostic_path] { + tracking_response( + app.clone(), + Request::builder().uri(&path).body(Body::empty()).unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + tracking_response( + app.clone(), + Request::builder() + .uri(path) + .header(axum::http::header::COOKIE, &cookie) + .body(Body::empty()) + .unwrap(), + StatusCode::OK, + ) + .await; + } + let answer_path = format!("{prefix}/tickets/{ticket}/assignment-answer"); + let request = |with_origin: bool| { + let mut request = Request::builder() + .method("POST") + .uri(&answer_path) + .header(CONTENT_TYPE, "application/json") + .header(axum::http::header::COOKIE, &cookie); + if with_origin { + request = request.header(ORIGIN, origin); + } + request + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap() + }; + tracking_response(app.clone(), request(false), StatusCode::FORBIDDEN).await; + let saved = tracking_response(app.clone(), request(true), StatusCode::OK).await; + if let Some(previous) = &receipt { + assert_eq!(&saved, previous); + } else { + receipt = Some(saved); + } + let cross = answer_path.replace(TEST_WORKSPACE_ID, "foreign-workspace"); + tracking_response( + app.clone(), + Request::builder() + .method("POST") + .uri(cross) + .header(CONTENT_TYPE, "application/json") + .header(axum::http::header::COOKIE, &cookie) + .header(ORIGIN, origin) + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + StatusCode::NOT_FOUND, + ) + .await; + } + let events = browser_ticket_backend(&api) + .unwrap() + .show(ticket.into()) + .unwrap() + .events; + let answers = events + .iter() + .filter(|event| event.kind.as_str() == "assignment_answer") + .collect::>(); + assert_eq!(answers.len(), 1); + assert!( + answers[0] + .author + .as_deref() + .unwrap() + .contains("account-assignment-cookie") + ); +} + +#[tokio::test] +async fn canonical_answer_uses_actual_runtime_binding_and_rejects_tampered_source_or_binding_replay() + { + let directory = tempfile::tempdir().unwrap(); + let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await; + let current = assign_tracking(&api, &identity, &source, &ticket).await; + let path = format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer"); + let input = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"runtime-answer","body":"Runtime-authenticated continuation"}); + let request = || { + runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ) + }; + let app = build_router(api.clone()); + let mut unsigned = request(); + unsigned + .headers_mut() + .remove(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER); + tracking_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await; + let mut tampered = request(); + *tampered.body_mut() = Body::from("{}"); + tracking_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await; + let attacker = RuntimeIdentityMaterial::generate(&source.runtime_id).unwrap(); + tracking_response( + app.clone(), + runtime_source_request( + &attacker, + Some(&source.worker_id), + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::UNAUTHORIZED, + ) + .await; + tracking_response( + app.clone(), + runtime_source_request( + &identity, + None, + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::FORBIDDEN, + ) + .await; + let saved = tracking_response(app.clone(), request(), StatusCode::OK).await; + assert_eq!( + tracking_response(app.clone(), request(), StatusCode::OK).await, + saved + ); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + let binding:String=conn.query_row("SELECT binding_id FROM workspace_runtime_bindings WHERE workspace_id=?1 AND runtime_id=?2",rusqlite::params![TEST_WORKSPACE_ID,source.runtime_id],|r| r.get(0)).unwrap(); + let receipt_source:String=conn.query_row("SELECT source_context_json FROM typed_ticket_assignment_tracking_operations WHERE workspace_id=?1 AND operation_key='runtime-answer'",[TEST_WORKSPACE_ID],|r| r.get(0)).unwrap(); + let receipt_source: serde_json::Value = serde_json::from_str(&receipt_source).unwrap(); + assert_eq!( + receipt_source["source"]["authentication_binding_id"], + binding + ); + assert_eq!(receipt_source["source"]["worker_id"], source.worker_id); + conn.execute("UPDATE workspace_runtime_bindings SET binding_id='new-answer-binding' WHERE workspace_id=?1 AND runtime_id=?2",rusqlite::params![TEST_WORKSPACE_ID,source.runtime_id]).unwrap(); + tracking_response(app.clone(), request(), StatusCode::CONFLICT).await; + let foreign = path.replace(TEST_WORKSPACE_ID, "foreign-workspace"); + let response = app + .oneshot(runtime_source_request( + &identity, + Some(&source.worker_id), + "POST", + &foreign, + serde_json::to_vec(&input).unwrap(), + )) + .await + .unwrap(); + assert!(!response.status().is_success()); + assert_eq!( + browser_ticket_backend(&api) + .unwrap() + .show(ticket.into()) + .unwrap() + .events + .iter() + .filter(|event| event.kind.as_str() == "assignment_answer") + .count(), + 1 + ); +} + +#[tokio::test] +async fn closure_reason_filter_matches_current_judgment_before_paging_and_text_search_retains_old_reasons() + { + let directory = tempfile::tempdir().unwrap(); + let api = test_api(directory.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Reason classification")) + .unwrap(); + let mutate = |key: &str, action: ticket::TicketCaseAction| { + let observed = backend.show(reference.id.clone().into()).unwrap(); + backend + .update_case( + &reference.id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&observed), + expected_state: observed.meta.workflow_state, + expected_closure_operation_key: observed.closure_operation_key, + operation_key: key.into(), + action, + explanation: "Case decision explanation, no reason name here".into(), + references: vec![], + author: Some("human".into()), + }, + ) + .unwrap(); + }; + mutate( + "close-not-planned", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + for n in 0..8 { + backend + .create(ticket::NewTicket::new(format!("Newer unrelated {n}"))) + .unwrap(); + } + let query = |value: serde_json::Value| { + api.authority + .query_tickets(serde_json::from_value(value).unwrap()) + .unwrap() + }; + assert_eq!( + query(json!({"closure_reason":"not_planned","limit":1})).items[0].id, + reference.id + ); + mutate( + "correct-duplicate", + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::Duplicate, + }, + ); + assert!( + query(json!({"closure_reason":"not_planned","limit":1})) + .items + .is_empty() + ); + assert_eq!( + query(json!({"closure_reason":"duplicate","limit":1})).items[0].id, + reference.id + ); + assert_eq!( + query(json!({"query":"not_planned","limit":1})).items[0].matched_fields, + vec!["closure_reason"] + ); + mutate("reopen", ticket::TicketCaseAction::Reopen); + assert!( + query(json!({"closure_reason":"duplicate","limit":1})) + .items + .is_empty() + ); + assert_eq!( + query(json!({"query":"duplicate","limit":1})).items[0].id, + reference.id + ); + mutate( + "close-custom", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Other("Superseded plan".into()), + }, + ); + assert_eq!( + query(json!({"closure_reason":{"other":"Superseded plan"},"limit":1})).items[0].id, + reference.id + ); + assert!( + query(json!({"closure_reason":{"other":"Other plan"},"limit":1})) + .items + .is_empty() + ); + assert_eq!( + query(json!({"query":"Superseded plan","limit":1})).items[0].id, + reference.id + ); +} diff --git a/crates/workspace-server/src/server_db_execution_tests.rs b/crates/workspace-server/src/server_db_execution_tests.rs new file mode 100644 index 000000000..08c179e6b --- /dev/null +++ b/crates/workspace-server/src/server_db_execution_tests.rs @@ -0,0 +1,1957 @@ +//! Real SQLite waits at the HTTP/authority boundary (not mock executor jobs). +#[path = "server_public_assignment_execution_tests.rs"] +mod public_assignment; +#[path = "server_residual_execution_tests.rs"] +mod residual; +use super::*; +use crate::SqliteWorkspaceStore; +use crate::db_executor::{DbExecutor, DbExecutorLimits, DbOperationLifetime}; +use rusqlite::{Connection, ffi}; +use std::{ + ffi::c_void, + future::Future, + num::NonZeroUsize, + sync::{atomic::AtomicBool, mpsc}, + thread::JoinHandle, + time::Duration as StdDuration, +}; +use tokio::sync::oneshot; + +const WORKSPACE: &str = "db-execution"; +const SUBJECTS: &str = "/api/w/db-execution/subjektiv/subjects"; +const OBSERVE_LIMIT: StdDuration = StdDuration::from_secs(2); +const WATCHDOG: StdDuration = StdDuration::from_secs(5); + +#[track_caller] +fn observed(future: impl Future) -> impl Future { + let caller = std::panic::Location::caller(); + async move { + tokio::time::timeout(OBSERVE_LIMIT, future) + .await + .unwrap_or_else(|_| { + panic!("request did not reach the expected DB/lifecycle state at {caller}") + }) + } +} + +struct Fixture { + // Keep temporary authority files alive until all other fixture owners drop. + server: WorkspaceServerApi, + api: WorkspaceApi, + executor: DbExecutor, + host_executor: DbExecutor, + store: Arc, + _dir: tempfile::TempDir, +} + +impl Fixture { + async fn new() -> Self { + Self::with_repositories(Vec::new()).await + } + + async fn with_repositories(repositories: Vec) -> Self { + let dir = tempfile::tempdir().unwrap(); + let executor = DbExecutor::new(DbExecutorLimits { + workers: NonZeroUsize::new(1).unwrap(), + queued: NonZeroUsize::new(1).unwrap(), + }) + .unwrap(); + // Explicit startup config: never resolve the developer's live data dir. + let config = ServerConfig { + workspace_id: WORKSPACE.into(), + workspace_display_name: "DB execution fixture".into(), + workspace_created_at: "2026-01-01T00:00:00Z".into(), + workspace_execution_root: dir.path().into(), + database_path: dir.path().join("server.db"), + data_root: dir.path().join("data"), + embedded_runtime_store_root: dir.path().join("runtime"), + static_assets_dir: None, + auth: AuthConfig::Passkey { + rp_id: "localhost".into(), + origin: "http://localhost:5173".into(), + public_base_url: "http://localhost:5173".into(), + cookie_name: "db_execution_session".into(), + }, + max_records: 100, + repositories, + runtime_event_sources: Vec::new(), + remote_runtime_sources: Vec::new(), + backend_base_url: Some("http://localhost:8787".into()), + }; + let store = Arc::new( + SqliteWorkspaceStore::open_for_test(&config.database_path) + .unwrap() + .with_db_executor(executor.clone()), + ); + seed_test_registered_workspace(store.as_ref(), &config) + .await + .unwrap(); + for repository in &config.repositories { + store + .upsert_repository(&crate::store::RepositoryRecord { + workspace_id: WORKSPACE.into(), + repository_id: repository.id.clone(), + repository_key: repository.repository_key.clone(), + kind: "git".into(), + provider: Some(repository.provider.clone()), + source: repository.source.clone(), + default_ref: repository.default_selector.clone(), + source_fingerprint: repository.source_fingerprint.clone(), + observed_status: repository.observed_status.clone(), + observed_at: repository.observed_at.clone(), + created_at: config.workspace_created_at.clone(), + updated_at: config.workspace_created_at.clone(), + }) + .unwrap(); + } + store + .upsert_user(&UserRecord { + user_id: "fixture-user".into(), + account_id: format!("account-{WORKSPACE}"), + handle: "fixture-user".into(), + display_name: "Fixture user".into(), + created_at: config.workspace_created_at.clone(), + updated_at: config.workspace_created_at.clone(), + }) + .unwrap(); + store + .create_browser_session(&crate::store::BrowserSessionRecord { + session_id: "fixture-session".into(), + token_hash: crate::auth::token_hash("fixture-browser-token"), + user_id: "fixture-user".into(), + created_at: config.workspace_created_at.clone(), + expires_at: "2999-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + // Explicit small Host budget proves the actual mixed mutation owner; + // the production/default fixture limits remain 2 workers/16 queued. + let host_executor = DbExecutor::new(DbExecutorLimits { + workers: NonZeroUsize::new(1).unwrap(), + queued: NonZeroUsize::new(1).unwrap(), + }) + .unwrap(); + let host_executors = HostExecutors { + host: Arc::new(std::sync::OnceLock::from(Some(host_executor.clone()))), + ..Default::default() + }; + let api = + WorkspaceApi::new_with_host_executors(config.clone(), store.clone(), host_executors) + .await + .unwrap(); + let server = WorkspaceServerApi::new_for_test(config, store.clone()); + // Cache only the real inner router; avoid starting the unrelated hook. + server + .apis + .lock() + .await + .insert(WORKSPACE.into(), api.clone()); + server + .routers + .lock() + .await + .insert(WORKSPACE.into(), build_inner_router(api.clone())); + executor.drain().await.unwrap(); + Self { + server, + api, + executor, + host_executor, + store, + _dir: dir, + } + } + + fn request(method: &str, path: &str, body: serde_json::Value) -> Request { + Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header("cookie", "db_execution_session=fixture-browser-token") + .header(ORIGIN, "http://localhost:5173") + .body(axum::body::Body::from(body.to_string())) + .unwrap() + } + + fn subject_request(role: &str) -> Request { + Self::request( + "POST", + SUBJECTS, + serde_json::json!({ "role": role, "behavior_md": "" }), + ) + } + + fn dispatch(&self, request: Request) -> tokio::task::JoinHandle { + let server = self.server.clone(); + tokio::spawn(async move { dispatch_workspace_request(State(server), request).await }) + } + + // A pre-authenticated generated router isolates handler admission from the + // earlier browser-session DB read. Full authentication/dispatch is exercised + // separately by the cancellation and warm-Feature tests below. + fn inner_router(&self) -> Router { + build_inner_router(self.api.clone()).layer(Extension(RequestActor { + user_id: "fixture-user".into(), + account_id: format!("account-{WORKSPACE}"), + handle: "fixture-user".into(), + display_name: "Fixture user".into(), + auth_method: ActorAuthMethod::BrowserSession, + })) + } + + fn feature_database(&self) -> (crate::FeatureDatabase, PathBuf) { + let database = self + .api + .feature_storage + .open(&self.api.subjektiv_registration) + .unwrap(); + // Initialize the trusted scope, then obtain SQLite's actual file path + // from the managed handle rather than guessing the Feature directory. + crate::subjektiv::SubjektivStore::from_database(database.clone()).unwrap(); + let path = database + .with_connection(|conn| { + Ok(PathBuf::from(conn.query_row( + "SELECT file FROM pragma_database_list WHERE name = 'main'", + [], + |row| row.get::<_, String>(0), + )?)) + }) + .unwrap(); + (database, path) + } +} + +/// SQLite write ownership lives on an OS thread, including a bounded watchdog. +/// Drop releases and joins it even when an assertion panics on the async side. +struct WriteLock { + release: Option>, + thread: Option>, + released: Arc, +} + +impl WriteLock { + fn acquire(path: PathBuf) -> Self { + let (ready, held) = mpsc::sync_channel(1); + let (release, permit) = mpsc::channel(); + let released = Arc::new(AtomicBool::new(false)); + let flag = released.clone(); + let thread = std::thread::spawn(move || { + let conn = Connection::open(path).unwrap(); + conn.execute_batch("BEGIN IMMEDIATE").unwrap(); + ready.send(()).unwrap(); + let _ = permit.recv_timeout(WATCHDOG); + conn.execute_batch("ROLLBACK").unwrap(); + flag.store(true, Ordering::SeqCst); + }); + held.recv_timeout(WATCHDOG) + .expect("SQLite lock holder did not acquire its transaction"); + Self { + release: Some(release), + thread: Some(thread), + released, + } + } + + fn release(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} +impl Drop for WriteLock { + fn drop(&mut self) { + self.release(); + } +} + +struct BusyGate { + entered: Option>, + resume: mpsc::Receiver<()>, +} + +/// rusqlite's fn-pointer busy_handler cannot capture a fixture. SQLite's +/// userdata API can, without process globals or changing production hooks. +enum BusyDatabase { + Feature(crate::FeatureDatabase), + Store(Arc), +} +impl BusyDatabase { + fn access(&self, access: impl FnOnce(&Connection)) { + match self { + Self::Feature(database) => database + .with_connection(|conn| { + access(conn); + Ok(()) + }) + .unwrap(), + Self::Store(store) => store + .with_conn(|conn| { + access(conn); + Ok(()) + }) + .unwrap(), + } + } +} +struct BusyObservation { + database: BusyDatabase, + gate: Box, + resume: Option>, +} +impl BusyObservation { + fn install(database: crate::FeatureDatabase) -> (Self, oneshot::Receiver<()>) { + Self::install_on(BusyDatabase::Feature(database)) + } + fn install_store(store: Arc) -> (Self, oneshot::Receiver<()>) { + Self::install_on(BusyDatabase::Store(store)) + } + fn install_on(database: BusyDatabase) -> (Self, oneshot::Receiver<()>) { + unsafe extern "C" fn busy(context: *mut c_void, _: i32) -> i32 { + // SAFETY: owned Box remains registered until Drop unregisters it, + // serialized by the managed connection mutex. + let gate = unsafe { &mut *context.cast::() }; + if let Some(entered) = gate.entered.take() { + let _ = entered.send(()); + return i32::from(gate.resume.recv_timeout(WATCHDOG).is_ok()); + } + 0 + } + let (entered, waiting) = oneshot::channel(); + let (resume, permit) = mpsc::channel(); + let mut gate = Box::new(BusyGate { + entered: Some(entered), + resume: permit, + }); + database.access(|conn| { + // SAFETY: handle belongs to this connection; the Box is stable. + let code = unsafe { + ffi::sqlite3_busy_handler( + conn.handle(), + Some(busy), + (&mut *gate as *mut BusyGate).cast(), + ) + }; + assert_eq!(code, ffi::SQLITE_OK); + }); + ( + Self { + database, + gate, + resume: Some(resume), + }, + waiting, + ) + } + fn resume(&mut self) { + if let Some(resume) = self.resume.take() { + let _ = resume.send(()); + } + } +} +impl Drop for BusyObservation { + fn drop(&mut self) { + self.resume(); + self.database.access(|conn| { + // SAFETY: unregister before the boxed userdata is freed. + unsafe { + ffi::sqlite3_busy_handler(conn.handle(), None, std::ptr::null_mut()); + } + }); + let _ = &self.gate; + } +} + +// A handler with one DB stage can expose its admission without adding a test +// hook to production. Polling its future to Pending proves run accepted it. +struct AdmissionObservation(Mutex>>); +impl DbOperationLifetime for AdmissionObservation { + fn retain(&self) -> Box { + if let Some(accepted) = self.0.lock().unwrap().take() { + let _ = accepted.send(()); + } + Box::new(()) + } +} +fn admission_observation() -> (Arc, oneshot::Receiver<()>) { + let (accepted, receiver) = oneshot::channel(); + ( + Arc::new(AdmissionObservation(Mutex::new(Some(accepted)))), + receiver, + ) +} + +struct StateCheckSaturation { + executor: DbExecutor, + stages: std::sync::atomic::AtomicUsize, + releases: Arc>>>, +} +impl DbOperationLifetime for StateCheckSaturation { + fn retain(&self) -> Box { + if self.stages.fetch_add(1, Ordering::SeqCst) == 1 { + // Authentication has completed; saturate just before the lifecycle + // lookup is admitted. The callback's own dispatch admissions are + // distinct stages and do not recurse into this branch. + let (started, running) = mpsc::channel(); + let (release, permit) = mpsc::channel(); + self.executor + .dispatch(move || { + let _ = started.send(()); + let _ = permit.recv_timeout(WATCHDOG); + }) + .unwrap(); + running + .recv_timeout(WATCHDOG) + .expect("state-check blocker did not start"); + self.releases.lock().unwrap().push(release); + let (release, permit) = mpsc::channel(); + self.executor + .dispatch(move || { + let _ = permit.recv_timeout(WATCHDOG); + }) + .unwrap(); + self.releases.lock().unwrap().push(release); + } + Box::new(()) + } +} +struct ReleaseStateCheckBlockers(Arc>>>); +impl Drop for ReleaseStateCheckBlockers { + fn drop(&mut self) { + self.0.lock().unwrap().clear(); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn saturated_mutation_lifecycle_lookup_returns_availability_not_deleting_conflict() { + let fixture = Fixture::new().await; + let releases = Arc::new(Mutex::new(Vec::new())); + let release = ReleaseStateCheckBlockers(releases.clone()); + let source = Arc::new(StateCheckSaturation { + executor: fixture.executor.clone(), + stages: std::sync::atomic::AtomicUsize::new(0), + releases, + }); + let router = Router::new() + .route( + SUBJECTS, + axum::routing::post(|| async { StatusCode::NO_CONTENT }), + ) + .layer(axum::middleware::from_fn_with_state( + fixture.api.clone(), + authorize_workspace_api_request, + )); + let response = observed(crate::db_executor::with_operation_lifetime( + source, + router.oneshot(Fixture::subject_request("not-executed")), + )) + .await + .unwrap(); + drop(release); + observed(fixture.executor.drain()).await.unwrap(); + let body = response_json(response, StatusCode::SERVICE_UNAVAILABLE).await; + assert!( + !body.to_string().contains("Workspace is deleting"), + "{body}" + ); +} + +async fn runtime_progress_while_locked(lock: &WriteLock) { + let control = tokio::spawn(async { "control progressed" }); + assert_eq!(observed(control).await.unwrap(), "control progressed"); + // The timer is the contract, not a guessed wait for request completion. + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; + assert!( + !lock.released.load(Ordering::SeqCst), + "SQLite watchdog released the lock before runtime progress" + ); +} +async fn response_json(response: Response, expected: StatusCode) -> serde_json::Value { + let status = response.status(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + assert_eq!(status, expected, "{}", String::from_utf8_lossy(&bytes)); + serde_json::from_slice(&bytes).expect("typed JSON response") +} +fn subject_count(path: &Path, role: &str) -> i64 { + Connection::open(path) + .unwrap() + .query_row( + "SELECT count(*) FROM subjects WHERE role = ?1", + [role], + |row| row.get(0), + ) + .unwrap() +} + +#[tokio::test(flavor = "current_thread")] +async fn ticket_create_waiting_for_sqlite_does_not_block_timer_or_control() { + let fixture = Fixture::new().await; + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let (lifetime, accepted) = admission_observation(); + let request = Fixture::request( + "POST", + "/api/w/db-execution/tickets", + serde_json::to_value(ticket::NewTicket::new("locked Ticket")).unwrap(), + ); + let mut response = Box::pin(crate::db_executor::with_operation_lifetime( + lifetime, + fixture.inner_router().oneshot(request), + )); + assert!(futures::poll!(&mut response).is_pending()); + observed(accepted).await.unwrap(); + runtime_progress_while_locked(&lock).await; + assert!( + futures::poll!(&mut response).is_pending(), + "mutation responded before the SQLite lock was released" + ); + let reader = Connection::open(&fixture.api.config.database_path).unwrap(); + let count: i64 = reader + .query_row( + "SELECT count(*) FROM typed_tickets WHERE workspace_id = ?1", + [WORKSPACE], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(count, 0, "Ticket must not be visible before commit"); + lock.release(); + let body = response_json(observed(response).await.unwrap(), StatusCode::OK).await; + assert!(body["id"].is_string(), "{body}"); + // Ticket dispatch can call Runtime callbacks, so its operation owner is + // the separate bounded host executor, not the store executor. + observed(fixture.api.host_executors.host().unwrap().drain()) + .await + .unwrap(); + fixture.executor.drain().await.unwrap(); + let count: i64 = reader.query_row("SELECT count(*) FROM typed_tickets WHERE workspace_id = ?1 AND title = 'locked Ticket'", [WORKSPACE], |row| row.get(0)).unwrap(); + assert_eq!(count, 1, "one accepted HTTP mutation must commit once"); +} + +#[tokio::test(flavor = "current_thread")] +async fn cold_subjektiv_open_waiting_for_scope_transaction_keeps_runtime_responsive() { + let fixture = Fixture::new().await; + let (database, path) = fixture.feature_database(); + drop(database); // FeatureStorage retains Weak handles: the route must cold-open. + let mut lock = WriteLock::acquire(path); + let (lifetime, accepted) = admission_observation(); + let request = Fixture::request("GET", SUBJECTS, serde_json::Value::Null); + let mut response = Box::pin(crate::db_executor::with_operation_lifetime( + lifetime, + fixture.inner_router().oneshot(request), + )); + assert!(futures::poll!(&mut response).is_pending()); + observed(accepted).await.unwrap(); + runtime_progress_while_locked(&lock).await; + assert!( + futures::poll!(&mut response).is_pending(), + "cold open must wait for its SQLite scope transaction" + ); + lock.release(); + let body = response_json(observed(response).await.unwrap(), StatusCode::OK).await; + assert_eq!(body["items"], serde_json::json!([]), "{body}"); + fixture.executor.drain().await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn authenticated_subjektiv_mutation_waits_for_commit_before_response() { + let fixture = Fixture::new().await; + let (database, path) = fixture.feature_database(); + let mut lock = WriteLock::acquire(path.clone()); + let (mut busy, waiting) = BusyObservation::install(database); + let response = fixture.dispatch(Fixture::subject_request("locked-subject")); + observed(waiting).await.unwrap(); + runtime_progress_while_locked(&lock).await; + assert!( + !response.is_finished(), + "HTTP response must not publish an uncommitted subject" + ); + assert_eq!(subject_count(&path, "locked-subject"), 0); + lock.release(); + busy.resume(); + let body = response_json(observed(response).await.unwrap(), StatusCode::CREATED).await; + assert_eq!(body["role"], "locked-subject", "{body}"); + assert!( + body["memory_fingerprint"].is_string(), + "response assembly must finish in the admitted mutation operation: {body}" + ); + fixture.host_executor.drain().await.unwrap(); + assert_eq!(subject_count(&path, "locked-subject"), 1); +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_http_waiter_keeps_deletion_pending_until_sql_finishes() { + let fixture = Fixture::new().await; + let (database, path) = fixture.feature_database(); + let mut lock = WriteLock::acquire(path.clone()); + let (mut busy, waiting) = BusyObservation::install(database); + let response = fixture.dispatch(Fixture::subject_request("cancelled-subject")); + observed(waiting).await.unwrap(); // Actual SQLite wait, not inferred task scheduling. + response.abort(); + assert!(observed(response).await.unwrap_err().is_cancelled()); + let admission = fixture.server.admission_for_workspace(WORKSPACE).await; + let mut deletion = Box::pin(admission.lock_deletion()); + assert!( + futures::poll!(&mut deletion).is_pending(), + "aborting HTTP must not drop the accepted SQL RequestLease" + ); + assert!( + admission.admit(false).is_none(), + "deletion must fence fresh requests" + ); + assert_eq!(subject_count(&path, "cancelled-subject"), 0); + lock.release(); + busy.resume(); + fixture.host_executor.drain().await.unwrap(); + let deletion = observed(deletion).await; + assert_eq!( + subject_count(&path, "cancelled-subject"), + 1, + "cancelled waiter must neither cancel nor retry accepted SQL" + ); + drop(deletion); + assert!( + admission.admit(false).is_some(), + "deletion lease cleanup must reopen admission" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_queued_http_mutation_keeps_capacity_until_commit_and_drain() { + let fixture = Fixture::new().await; + let (database, path) = fixture.feature_database(); + let mut lock = WriteLock::acquire(path.clone()); + let (mut busy, waiting) = BusyObservation::install(database); + let running = fixture.dispatch(Fixture::subject_request("running-subject")); + observed(waiting).await.unwrap(); + let (lifetime, accepted) = admission_observation(); + let mut queued = Box::pin(crate::db_executor::with_operation_lifetime( + lifetime, + fixture + .inner_router() + .oneshot(Fixture::subject_request("queued-subject")), + )); + assert!(futures::poll!(&mut queued).is_pending()); + observed(accepted).await.unwrap(); + drop(queued); // Accepted job still owns the only waiting slot. + let rejected = observed( + fixture + .inner_router() + .oneshot(Fixture::subject_request("rejected-subject")), + ) + .await + .unwrap(); + let body = response_json(rejected, StatusCode::SERVICE_UNAVAILABLE).await; + let error: server_api::RepositoryApiError = serde_json::from_value(body.clone()).unwrap(); + assert!( + error.message.contains("capacity"), + "typed overload error: {body}" + ); + assert_eq!(subject_count(&path, "queued-subject"), 0); + assert_eq!(subject_count(&path, "rejected-subject"), 0); + let mut drained = Box::pin(fixture.host_executor.drain()); + assert!(futures::poll!(&mut drained).is_pending()); + lock.release(); + busy.resume(); + response_json(observed(running).await.unwrap(), StatusCode::CREATED).await; + observed(drained).await.unwrap(); + assert_eq!( + subject_count(&path, "queued-subject"), + 1, + "cancelled accepted mutation persists exactly once" + ); + assert_eq!( + subject_count(&path, "rejected-subject"), + 0, + "overload must not execute rejected SQL" + ); + let retry = fixture.dispatch(Fixture::subject_request("after-drain")); + response_json(observed(retry).await.unwrap(), StatusCode::CREATED).await; + fixture.host_executor.drain().await.unwrap(); + assert_eq!( + subject_count(&path, "after-drain"), + 1, + "drain must release actual-job capacity" + ); +} + +#[tokio::test(start_paused = true)] +async fn saturated_cancelled_host_jobs_keep_their_budget_but_not_other_fixture_or_cleanup_admission() + { + use std::sync::{ + Condvar, + atomic::{AtomicUsize, Ordering}, + }; + // A drop guard releases workers even when an assertion panics. + struct Release(Arc<(Mutex, Condvar)>); + impl Drop for Release { + fn drop(&mut self) { + *self.0.0.lock().unwrap() = true; + self.0.1.notify_all(); + } + } + let owner = HostExecutors::default(); + let other = HostExecutors::default(); + let host = owner.host().unwrap(); + let gate = Arc::new((Mutex::new(false), Condvar::new())); + let release = Release(gate.clone()); + let completed = Arc::new(AtomicUsize::new(0)); + let mut waiters = Vec::new(); + for _ in 0..2 { + let host = host.clone(); + let gate = gate.clone(); + let completed = completed.clone(); + let (started, accepted) = tokio::sync::oneshot::channel(); + waiters.push(tokio::spawn(async move { + host.run(move || { + started.send(()).unwrap(); + let mut released = gate.0.lock().unwrap(); + while !*released { + released = gate.1.wait(released).unwrap(); + } + completed.fetch_add(1, Ordering::SeqCst); + Ok::<_, Error>(()) + }) + .await + })); + accepted.await.unwrap(); + } + for _ in 0..16 { + let completed = completed.clone(); + host.dispatch(move || { + completed.fetch_add(1, Ordering::SeqCst); + }) + .unwrap(); + } + for waiter in waiters { + waiter.abort(); + assert!(waiter.await.unwrap_err().is_cancelled()); + } + assert!(matches!( + host.run(|| Ok::<_, Error>(())).await, + Err(Error::DbCapacityExceeded) + )); + // Paused Tokio time can advance without completing or releasing actual Host + // work. Neither another fixture nor cleanup needs this saturated Host lane. + tokio::time::advance(std::time::Duration::from_secs(31)).await; + assert_eq!(completed.load(Ordering::SeqCst), 0); + other + .host() + .unwrap() + .run(|| Ok::<_, Error>(())) + .await + .unwrap(); + owner + .cleanup() + .unwrap() + .run(|| Ok::<_, Error>(())) + .await + .unwrap(); + assert!(matches!( + host.run(|| Ok::<_, Error>(())).await, + Err(Error::DbCapacityExceeded) + )); + drop(release); + host.drain().await.unwrap(); + assert_eq!(completed.load(Ordering::SeqCst), 18); + host.run(|| Ok::<_, Error>(())).await.unwrap(); + host.drain().await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn restore_intent_pins_once_after_write_ownership_without_a_deferred_read_upgrade() { + let fixture = Fixture::new().await; + let store = fixture.api.config_store.clone(); + let (mut busy, waiting) = BusyObservation::install_store(store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let worker = RuntimeWorkerRef::new("restore-runtime", "restore-worker"); + let pin_store = store.clone(); + let pin_worker = worker.clone(); + let mut pinned = Box::pin(fixture.executor.run(move || { + pin_store.pin_internal_worker_restore_intent( + WORKSPACE, + &pin_worker, + "restore-domain", + Some("observed-before-dispatch"), + Some("restore-request"), + ) + })); + tokio::select! { + result = &mut pinned => panic!("restore intent must wait for write ownership before reading, not fail a deferred read upgrade: {result:?}"), + arrived = observed(waiting) => arrived.unwrap(), + } + runtime_progress_while_locked(&lock).await; + assert!(futures::poll!(&mut pinned).is_pending()); + lock.release(); + busy.resume(); + let request = observed(pinned).await.unwrap(); + assert_eq!(request.request_id, "restore-request"); + assert_eq!( + request.expected_observation_token, + "observed-before-dispatch" + ); + fixture.executor.drain().await.unwrap(); + let saved = store + .get_internal_worker_restore_intent(WORKSPACE, &worker, &request.request_id) + .unwrap() + .unwrap(); + assert_eq!(saved.request_id, request.request_id); + assert_eq!( + saved.expected_observation_token, + request.expected_observation_token + ); + let count: i64 = store + .with_conn(|conn| { + Ok(conn.query_row( + "SELECT count(*) FROM worker_restore_intents WHERE workspace_id=?1 AND request_id=?2", + rusqlite::params![WORKSPACE, request.request_id], |row| row.get(0) + )?) + }) + .unwrap(); + assert_eq!( + count, 1, + "one admitted intent must persist exactly once, with no operation retry" + ); +} + +fn install_remote_fixture( + fixture: &Fixture, + runtime_id: &str, + base_url: &str, + signed: bool, +) -> crate::store::WorkspaceRuntimeBinding { + let identity = worker_runtime::auth::RuntimeIdentityMaterial::generate(runtime_id).unwrap(); + fixture + .store + .upsert_workspace_runtime_binding( + crate::store::WorkspaceRuntimeBinding { + workspace_id: WORKSPACE.into(), + runtime_id: runtime_id.into(), + display_name: "Held Runtime".into(), + base_url: base_url.into(), + public_key: identity.public_key, + public_key_fingerprint: String::new(), + binding_id: String::new(), + authentication_mode: if signed { + crate::store::WorkspaceRuntimeAuthenticationMode::WorkspaceIdentity + } else { + crate::store::WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer + }, + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + revoked_at: None, + }, + false, + ) + .unwrap(); + let binding = fixture + .store + .get_workspace_runtime_binding(WORKSPACE, runtime_id) + .unwrap() + .unwrap(); + // This narrow fixture exercises the provider's own live authority callback; + // it does not start the unrelated subscription/projection reconnect producer. + fixture.api.runtime.set_runtime_binding_gate(|_| true); + let mut config = crate::hosts::RemoteRuntimeConfig::new( + runtime_id, + "Held Runtime", + base_url, + Some("must-not-fall-back".into()), + ) + .with_timeout(WATCHDOG); + if signed { + fixture + .api + .signing_identities + .provision_existing(WORKSPACE, &format!("account-{WORKSPACE}")) + .unwrap(); + config.workspace_authorization = Some(WorkspaceRuntimeAuthorization::new( + fixture.store.clone(), + fixture.api.signing_identities.clone(), + "http://localhost:8787", + binding.clone(), + )); + } + fixture.api.runtime.register_or_replace( + RemoteWorkerRuntime::new(config, WORKSPACE.into(), "http://localhost:8787".into()).unwrap(), + ); + binding +} + +#[tokio::test(flavor = "current_thread")] +async fn cold_protocol_source_signing_waits_off_tokio_and_never_falls_back_on_overload_or_revocation() + { + let fixture = Fixture::new().await; + let binding = install_remote_fixture(&fixture, "protocol-runtime", "http://127.0.0.1:9", true); + let worker = RuntimeWorkerRef::new(&binding.runtime_id, "source-worker"); + let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let writer = fixture.store.clone(); + fixture + .executor + .dispatch(move || { + writer + .mark_api_token_used("missing-token", "2026-01-01T00:00:00Z") + .unwrap() + }) + .unwrap(); + observed(waiting).await.unwrap(); // SQL holds the actual shared connection mutex. + let mut source = Box::pin(workspace_worker_protocol_source(&fixture.api, &worker)); + assert!(futures::poll!(&mut source).is_pending()); + runtime_progress_while_locked(&lock).await; + assert!(futures::poll!(&mut source).is_pending()); + assert!( + matches!( + workspace_worker_protocol_source(&fixture.api, &worker).await, + Err(Error::DbCapacityExceeded) + ), + "overload must not return an unsigned or legacy-bearer source" + ); + lock.release(); + busy.resume(); + let RuntimeObservationSource::RemoteWs(config) = observed(source).await.unwrap() else { + panic!("remote source expected") + }; + let first = config + .bearer_token + .expect("fresh Workspace capability is required"); + assert_ne!(first, "must-not-fall-back"); + let RuntimeObservationSource::RemoteWs(second) = + workspace_worker_protocol_source(&fixture.api, &worker) + .await + .unwrap() + else { + panic!("remote source expected") + }; + assert_ne!( + first, + second.bearer_token.unwrap(), + "cold lookup must sign a fresh request, not cache authentication success" + ); + fixture + .store + .revoke_workspace_runtime_binding(WORKSPACE, &binding.runtime_id, "2026-01-02T00:00:00Z") + .unwrap(); + let error = workspace_worker_protocol_source(&fixture.api, &worker) + .await + .err() + .expect("revoked authority cannot produce any source"); + assert!( + matches!(error, Error::RuntimeOperationFailed { ref code, .. } if code == "workspace_runtime_authorization_stale"), + "{error:?}" + ); + fixture.executor.drain().await.unwrap(); +} + +// Loopback fake at the Session/Worker Runtime HTTP boundary. It owns one reply, +// released by an explicit event; Drop unblocks accept/read and joins on failure. +struct HeldRuntimeReply { + address: std::net::SocketAddr, + entered: Option>, + release: Option>, + upstream_released: Arc, + thread: Option>, +} +impl HeldRuntimeReply { + fn new() -> Self { + Self::with_held_path(None) + } + fn for_path(path: &str) -> Self { + Self::with_held_path(Some(path.to_owned())) + } + fn with_held_path(path: Option) -> Self { + use std::io::Write; + let listener = std::net::TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let (entered, received) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + let upstream_released = Arc::new(AtomicBool::new(false)); + let flag = upstream_released.clone(); + let thread = std::thread::spawn(move || { + loop { + let Ok((mut stream, _)) = listener.accept() else { + return; + }; + stream.set_read_timeout(Some(WATCHDOG)).unwrap(); + let Ok(Some(request)) = read_runtime_fixture_request(&mut stream) else { + return; + }; + if path.as_ref().is_some_and(|path| !request.contains(path)) { + // Ready's best-effort notification lookup precedes its detail + // source observation. Deny it promptly; hold only the named stage. + let _ = stream.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + continue; + } + let _ = entered.send(request); + let _ = permit.recv_timeout(WATCHDOG); + flag.store(true, Ordering::SeqCst); + let _ = stream.write_all(b"HTTP/1.1 503 Service Unavailable\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"); + break; + } + }); + Self { + address, + entered: Some(received), + release: Some(release), + upstream_released, + thread: Some(thread), + } + } + fn finish(&mut self) { + self.release.take(); + let _ = std::net::TcpStream::connect(self.address); + if let Some(thread) = self.thread.take() { + thread.join().unwrap(); + } + } +} +impl Drop for HeldRuntimeReply { + fn drop(&mut self) { + self.finish(); + } +} + +// Read one complete request before selecting a held path or denying a prior +// notification. TCP reads are not HTTP frame boundaries; replying while a POST +// body remains unread can also reset the connection instead of delivering 503. +fn read_runtime_fixture_request(reader: impl std::io::Read) -> std::io::Result> { + use std::io::{BufRead, Read}; + let mut reader = std::io::BufReader::new(reader); + let mut line = String::new(); + if reader.read_line(&mut line)? == 0 { + return Ok(None); + } + let request = line.trim_end().to_owned(); + let mut header_bytes = line.len(); + let mut body_len = 0; + loop { + line.clear(); + if reader.read_line(&mut line)? == 0 { + return Err(std::io::ErrorKind::UnexpectedEof.into()); + } + header_bytes += line.len(); + if header_bytes > 16384 { + return Err(std::io::ErrorKind::InvalidData.into()); + } + if line == "\r\n" { + break; + } + if let Some((name, value)) = line.split_once(':') { + if name.eq_ignore_ascii_case("content-length") { + body_len = value + .trim() + .parse::() + .map_err(|_| std::io::ErrorKind::InvalidData)?; + if body_len > 1024 * 1024 { + return Err(std::io::ErrorKind::InvalidData.into()); + } + } + if name.eq_ignore_ascii_case("transfer-encoding") { + return Err(std::io::ErrorKind::InvalidData.into()); + } + } + } + let mut body = vec![0; body_len]; + reader.read_exact(&mut body)?; + Ok(Some(request)) +} + +#[test] +fn held_runtime_fixture_consumes_fragmented_headers_and_body_before_selecting_the_path() { + struct Fragmented(std::io::Cursor>); + impl std::io::Read for Fragmented { + fn read(&mut self, buffer: &mut [u8]) -> std::io::Result { + let length = buffer.len().min(2); + std::io::Read::read(&mut self.0, &mut buffer[..length]) + } + } + let request = b"POST /v1/repository-refs/observe HTTP/1.1\r\nHost: localhost\r\nContent-Length: 5\r\n\r\nnotes"; + let mut reader = Fragmented(std::io::Cursor::new(request.to_vec())); + assert_eq!( + read_runtime_fixture_request(&mut reader) + .unwrap() + .as_deref(), + Some("POST /v1/repository-refs/observe HTTP/1.1") + ); + assert_eq!(reader.0.position(), request.len() as u64); + let truncated = &request[..request.len() - 1]; + assert_eq!( + read_runtime_fixture_request(truncated).unwrap_err().kind(), + std::io::ErrorKind::UnexpectedEof + ); +} + +struct CountJobLifetime(Arc); +struct CountJobLease(Arc); +impl DbOperationLifetime for CountJobLifetime { + fn retain(&self) -> Box { + self.0.fetch_add(1, Ordering::SeqCst); + Box::new(CountJobLease(self.0.clone())) + } +} +impl Drop for CountJobLease { + fn drop(&mut self) { + self.0.fetch_sub(1, Ordering::SeqCst); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn subjektiv_runtime_waits_leave_db_and_control_free_and_cancel_retains_the_actual_host_lease() + { + // Different mixed helpers carry this rule: Session Read/Search, subject + // projection, and legacy consolidation scope authorization (even refusal). + for kind in ["read", "search", "subject", "legacy_scope"] { + let fixture = Fixture::new().await; + let mut reply = HeldRuntimeReply::new(); + install_remote_fixture( + &fixture, + "subject-runtime", + &format!("http://{}", reply.address), + false, + ); + let Json(subject) = scoped_create_subjektiv_subject( + State(fixture.api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: WORKSPACE.into(), + }), + Json(server_api::SubjektivSubjectCreateRequest { + role: "network subject".into(), + behavior_md: String::new(), + }), + ) + .await + .unwrap() + .1; + let singleton = if kind == "legacy_scope" { + format!("{SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX}{}", subject.id) + } else { + crate::subjektiv::subject_worker_singleton_key(&subject.id).unwrap() + }; + let memory = fixture + .store + .get_workspace_memory_settings(WORKSPACE) + .unwrap(); + let reservation = fixture + .store + .reserve_worker_create( + WORKSPACE, + "subject-runtime", + "network-fixture", + "network-fixture-fingerprint", + Some(&singleton), + &memory, + ) + .unwrap(); + let worker = RuntimeWorkerRef::new("subject-runtime", reservation.worker_id.to_string()); + fixture + .store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: WORKSPACE.into(), + worker: worker.clone(), + display_name: "network fixture".into(), + profile: None, + retention_state: "normal".into(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + }) + .unwrap(); + fixture + .store + .complete_worker_create_reservation(WORKSPACE, reservation.worker_id) + .unwrap(); + open_subjektiv_store(&fixture.api) + .unwrap() + .record_session_attribution( + crate::subjektiv::SubjectSessionAttribution::new( + subject.id.clone(), + worker.runtime_id.clone(), + worker.worker_id.clone(), + "session", + ) + .unwrap(), + ) + .unwrap(); + fixture + .store + .create_worker_control_grant(&WorkerControlGrantRecord { + workspace_id: WORKSPACE.into(), + grant_id: "session-observe".into(), + controller: worker.clone(), + subject: worker.clone(), + relation: "subject_session".into(), + origin: "test".into(), + permissions: vec!["observe".into()], + operation_id: "session-observe".into(), + created_at: "2026-01-01T00:00:00Z".into(), + revoked_at: None, + }) + .unwrap(); + let context = server_api::ServerRequestContext { + actor: None, + worker_source: None, + runtime_source: Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), + runtime_id: worker.runtime_id, + worker_id: Some(worker.worker_id), + }), + origin: None, + transport_headers: Vec::new(), + }; + let counts = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let lifetime = Arc::new(CountJobLifetime(counts.clone())); + let api = fixture.api.clone(); + let task = tokio::spawn(crate::db_executor::with_operation_lifetime( + lifetime, + async move { + if kind == "subject" { + scoped_get_subjektiv_subject( + State(api), + AxumPath(ScopedSubjektivSubjectPath { + workspace_id: WORKSPACE.into(), + subject_id: subject.id, + }), + ) + .await + .map(|_| ()) + } else { + let operation = match kind { + "read" => server_api::SubjektivSessionBackendOperation::Read( + server_api::SubjektivSessionReadRequest { + session_id: "session".into(), + segment_id: "segment".into(), + entry_ref: "E1".into(), + mode: Default::default(), + cursor: None, + }, + ), + "search" => server_api::SubjektivSessionBackendOperation::Search( + serde_json::from_value( + serde_json::json!({"session_id":"session","query":"needle"}), + ) + .unwrap(), + ), + _ => server_api::SubjektivSessionBackendOperation::List( + serde_json::from_value(serde_json::json!({})).unwrap(), + ), + }; + scoped_subjektiv_session_backend( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: WORKSPACE.into(), + }), + context, + Json(server_api::SubjektivSessionBackendRequest { operation }), + ) + .await + .map(|_| ()) + } + }, + )); + let request = observed(reply.entered.take().unwrap()).await.unwrap(); + assert!( + request.contains(if kind == "read" || kind == "search" { + "/v1/session-public/" + } else { + "/v1/workers/" + }), + "{kind}: {request}" + ); + let read_store = fixture.store.clone(); + observed( + fixture + .executor + .run(move || read_store.get_workspace_memory_settings(WORKSPACE)), + ) + .await + .unwrap(); + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; // timer contract, not a wait guess + assert!( + !task.is_finished(), + "{kind} completed before upstream release" + ); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!( + counts.load(Ordering::SeqCst), + 1, + "{kind}: cancellation lost actual Host authority" + ); + reply.finish(); + observed(fixture.host_executor.drain()).await.unwrap(); + assert_eq!( + counts.load(Ordering::SeqCst), + 0, + "{kind}: completed Host job leaked its lease" + ); + fixture.executor.drain().await.unwrap(); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn runtime_registration_scan_keeps_sql_and_tokio_free_and_retains_cancelled_host_budget() { + let fixture = Fixture::new().await; + let mut reply = HeldRuntimeReply::new(); + install_remote_fixture( + &fixture, + "scan-runtime", + &format!("http://{}", reply.address), + true, + ); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let api = fixture.api.clone(); + let task = tokio::spawn(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + async move { scan_runtime_registration_workers(&api, "scan-runtime").await }, + )); + let request = observed(reply.entered.take().unwrap()).await.unwrap(); + assert!(request.contains("/v1/workers"), "{request}"); + assert!( + !reply.upstream_released.load(Ordering::SeqCst), + "registration scan blocked Tokio until watchdog bailout" + ); + let store = fixture.store.clone(); + observed( + fixture + .executor + .run(move || store.get_workspace_memory_settings(WORKSPACE)), + ) + .await + .unwrap(); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; + let mut queued_control = Box::pin(fixture.host_executor.run(|| Ok::<_, Error>(()))); + assert!(futures::poll!(&mut queued_control).is_pending()); + let rejected = scan_runtime_registration_workers(&fixture.api, "scan-runtime") + .await + .unwrap_err(); + response_json(rejected.into_response(), StatusCode::SERVICE_UNAVAILABLE).await; + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!(leases.load(Ordering::SeqCst), 1); + assert!(!reply.upstream_released.load(Ordering::SeqCst)); + reply.finish(); + observed(queued_control).await.unwrap(); + observed(fixture.host_executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + fixture.executor.drain().await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn runtime_management_authority_waits_off_tokio_and_retains_cancelled_db_admission() { + for stage in ["identity", "binding"] { + let fixture = Fixture::new().await; + let binding = + install_remote_fixture(&fixture, "authority-runtime", "http://127.0.0.1:9", true); + let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let writer = fixture.store.clone(); + fixture + .executor + .dispatch(move || { + writer + .mark_api_token_used("missing-token", "2026-01-01T00:00:00Z") + .unwrap(); + }) + .unwrap(); + observed(waiting).await.unwrap(); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let api = fixture.api.clone(); + let expected = binding.clone(); + let mut authority = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + async move { + if stage == "identity" { + require_active_runtime_registration_identity(&api).await + } else { + require_current_runtime_test_binding(&api, expected) + .await + .map(|_| ()) + } + }, + )); + assert!( + futures::poll!(&mut authority).is_pending(), + "{stage}: authority must queue without blocking Tokio on the shared mutex" + ); + runtime_progress_while_locked(&lock).await; + let overload = if stage == "identity" { + require_active_runtime_registration_identity(&fixture.api) + .await + .unwrap_err() + } else { + require_current_runtime_test_binding(&fixture.api, binding.clone()) + .await + .unwrap_err() + }; + response_json(overload.into_response(), StatusCode::SERVICE_UNAVAILABLE).await; + drop(authority); + assert_eq!( + leases.load(Ordering::SeqCst), + 1, + "{stage}: cancelled DB authority lost its lease" + ); + lock.release(); + busy.resume(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + require_active_runtime_registration_identity(&fixture.api) + .await + .unwrap(); + assert_eq!( + require_current_runtime_test_binding(&fixture.api, binding.clone()) + .await + .unwrap() + .binding_id, + binding.binding_id + ); + fixture + .store + .revoke_workspace_runtime_binding( + WORKSPACE, + &binding.runtime_id, + "2026-01-02T00:00:00Z", + ) + .unwrap(); + let stale = require_current_runtime_test_binding(&fixture.api, binding) + .await + .unwrap_err(); + response_json(stale.into_response(), StatusCode::CONFLICT).await; + } +} + +#[tokio::test(flavor = "current_thread")] +async fn runtime_connection_post_ping_sql_waits_off_tokio_while_host_control_progresses() { + let fixture = Fixture::new().await; + let mut reply = HeldRuntimeReply::new(); + let binding = install_remote_fixture( + &fixture, + "ping-runtime", + &format!("http://{}", reply.address), + true, + ); + let mut test = Box::pin(test_runtime_connection( + State(fixture.api.clone()), + AxumPath(binding.runtime_id.clone()), + )); + assert!(futures::poll!(&mut test).is_pending()); + observed(fixture.executor.drain()).await.unwrap(); + assert!(futures::poll!(&mut test).is_pending()); + let request = observed(reply.entered.take().unwrap()).await.unwrap(); + assert!(request.contains("/ping"), "{request}"); + let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let writer = fixture.store.clone(); + fixture + .executor + .dispatch(move || { + writer + .mark_api_token_used("missing-token", "2026-01-01T00:00:00Z") + .unwrap(); + }) + .unwrap(); + observed(waiting).await.unwrap(); + reply.finish(); + observed(fixture.host_executor.drain()).await.unwrap(); + assert!( + futures::poll!(&mut test).is_pending(), + "post-ping binding fence must await DB without a synchronous mutex wait" + ); + runtime_progress_while_locked(&lock).await; + assert_eq!( + observed(fixture.host_executor.run(|| Ok::<_, Error>("control"))) + .await + .unwrap(), + "control" + ); + lock.release(); + busy.resume(); + let Json(result) = observed(test).await.unwrap(); + assert_eq!(result.binding_id, binding.binding_id); + fixture.executor.drain().await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn saturated_host_rejects_runtime_key_revoke_without_mutating_trust() { + let fixture = Fixture::new().await; + fixture.api.worker_projection.shutdown_async().await; + observed(fixture.executor.drain()).await.unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let binding = install_remote_fixture( + &fixture, + "revoke-runtime", + &format!("http://{}", listener.local_addr().unwrap()), + true, + ); + fixture + .api + .runtime_binding_expectations + .write() + .unwrap() + .insert( + (WORKSPACE.into(), binding.runtime_id.clone()), + binding.clone(), + ); + let initial_registrations = fixture.api.runtime_subscription_broker.runtime_ids(); + // Sender Drop also releases each controlled job if a route assertion panics. + let mut releases: Vec> = Vec::new(); + let (started, running) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + releases.push(release); + fixture + .host_executor + .dispatch(move || { + let _ = started.send(()); + let _ = permit.recv_timeout(WATCHDOG); + }) + .unwrap(); + observed(running).await.unwrap(); + let (release, permit) = mpsc::channel(); + releases.push(release); + fixture + .host_executor + .dispatch(move || { + let _ = permit.recv_timeout(WATCHDOG); + }) + .unwrap(); + let body = response_json( + observed(fixture.dispatch(Fixture::request( + "DELETE", + "/api/w/db-execution/runtimes/revoke-runtime/trust-key", + serde_json::json!({"expected_binding_id": binding.binding_id}), + ))) + .await + .unwrap(), + StatusCode::SERVICE_UNAVAILABLE, + ) + .await; + assert!(body.to_string().contains("db_capacity_exceeded"), "{body}"); + assert_eq!( + fixture + .store + .get_workspace_runtime_binding(WORKSPACE, &binding.runtime_id) + .unwrap() + .unwrap(), + binding + ); + assert!( + fixture + .store + .list_workspace_runtime_binding_audit(WORKSPACE, &binding.runtime_id, 10) + .unwrap() + .is_empty() + ); + assert_eq!( + fixture.api.runtime_subscription_broker.runtime_ids(), + initial_registrations + ); + assert_eq!( + fixture + .api + .runtime_binding_expectations + .read() + .unwrap() + .get(&(WORKSPACE.into(), binding.runtime_id.clone())), + Some(&binding) + ); + drop(releases); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_runtime_key_revoke_commits_audit_and_removes_broker_registration() { + let fixture = Fixture::new().await; + // Stop unrelated projection writes before observing this mutation's SQLite + // wait. Keep the original runtime alive through actual workflow cleanup. + fixture.api.worker_projection.shutdown_async().await; + observed(fixture.executor.drain()).await.unwrap(); + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let binding = install_remote_fixture( + &fixture, + "revoke-runtime", + &format!("http://{}", listener.local_addr().unwrap()), + true, + ); + fixture + .api + .runtime_binding_expectations + .write() + .unwrap() + .insert( + (WORKSPACE.into(), binding.runtime_id.clone()), + binding.clone(), + ); + let mut config = remote_runtime_config_from_binding(&binding).unwrap(); + config.workspace_authorization = Some(WorkspaceRuntimeAuthorization::new( + fixture.store.clone(), + fixture.api.signing_identities.clone(), + "http://localhost:8787", + binding.clone(), + )); + fixture + .api + .runtime_subscription_broker + .register_remote_runtime(config); + let mut subscription = fixture + .api + .runtime_subscription_broker + .subscribe( + &binding.runtime_id, + protocol::subscription::EventSubscriptionSelector::RuntimeWorkers, + ) + .unwrap(); + // Broker registration starts an independent cold signing DB job. Observe + // its actual handshake before occupying SQL for revoke: otherwise this + // fixture races that producer against HTTP auth in its 1+1 DB budget. + // Keep the handshake pending so downstream shutdown is still exercised. + let (mut upstream, _) = observed(listener.accept()).await.unwrap(); + let handshake = observed(async { + use tokio::io::AsyncReadExt; + let mut bytes = Vec::new(); + while !bytes.ends_with(b"\r\n\r\n") { + let mut chunk = [0; 1024]; + let count = upstream.read(&mut chunk).await.unwrap(); + assert_ne!(count, 0, "broker closed before sending its handshake"); + bytes.extend_from_slice(&chunk[..count]); + assert!(bytes.len() <= 8192, "unexpectedly large handshake"); + } + String::from_utf8(bytes).unwrap() + }) + .await; + assert!(handshake.starts_with("GET /v1/protocol/ws ")); + assert!( + handshake + .to_ascii_lowercase() + .contains("authorization: bearer ") + ); + observed(fixture.executor.drain()).await.unwrap(); + let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let server = fixture.server.clone(); + let request = Fixture::request( + "DELETE", + "/api/w/db-execution/runtimes/revoke-runtime/trust-key", + serde_json::json!({"expected_binding_id": binding.binding_id}), + ); + let mut revoke = + tokio::spawn(async move { dispatch_workspace_request(State(server), request).await }); + tokio::select! { + reached = observed(waiting) => reached.unwrap(), // Actual authenticated BEGIN IMMEDIATE. + response = &mut revoke => { + let response = response.unwrap(); + let status = response.status(); + let body = axum::body::to_bytes(response.into_body(), 65536).await.unwrap(); + panic!("revoke returned before its SQLite wait: {status}: {}", String::from_utf8_lossy(&body)); + } + } + runtime_progress_while_locked(&lock).await; + revoke.abort(); + assert!(revoke.await.unwrap_err().is_cancelled()); + let admission = fixture.server.admission_for_workspace(WORKSPACE).await; + let mut deletion = Box::pin(admission.lock_deletion()); + assert!( + futures::poll!(&mut deletion).is_pending(), + "cancelled HTTP must retain actual revoke ownership until publication" + ); + assert!( + fixture + .api + .runtime_subscription_broker + .runtime_ids() + .contains(&binding.runtime_id) + ); + lock.release(); + busy.resume(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + let _deletion = observed(deletion).await; + let saved = fixture + .store + .get_workspace_runtime_binding(WORKSPACE, &binding.runtime_id) + .unwrap() + .unwrap(); + assert!(saved.revoked_at.is_some()); + assert_ne!(saved.binding_id, binding.binding_id); + let audit = fixture + .store + .list_workspace_runtime_binding_audit(WORKSPACE, &binding.runtime_id, 10) + .unwrap(); + assert_eq!( + audit.len(), + 1, + "cancelled mutation must commit one audit without retry" + ); + assert_eq!(audit[0].action, "revoked"); + assert_eq!(audit[0].actor_account_id, format!("account-{WORKSPACE}")); + assert_eq!(audit[0].binding_id, saved.binding_id); + assert_eq!(audit[0].at, saved.revoked_at.unwrap()); + assert!( + !fixture + .api + .runtime_subscription_broker + .runtime_ids() + .contains(&binding.runtime_id), + "committed revoke must unregister even after HTTP waiter cancellation" + ); + assert!( + !fixture + .api + .runtime_binding_expectations + .read() + .unwrap() + .contains_key(&(WORKSPACE.into(), binding.runtime_id.clone())) + ); + // Exercise downstream termination at the broker protocol boundary, not a + // product process or complete browser/Runtime E2E flow. + observed(async { + loop { + match subscription.recv().await { + Some(crate::runtime_subscription::BrokerSubscriptionEvent::Closed { + code, .. + }) => { + assert_eq!( + code, + protocol::subscription::SubscriptionTerminationCode::ServerShutdown + ); + break; + } + Some(crate::runtime_subscription::BrokerSubscriptionEvent::Disconnected { + .. + }) => {} + event => panic!("expected revoke shutdown, got {event:?}"), + } + } + }) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_runtime_metadata_mutation_keeps_commit_and_registration_publication_owned() { + let fixture = Fixture::new().await; + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let endpoint = format!("http://{}", listener.local_addr().unwrap()); + let server = tokio::spawn(async move { + axum::serve( + listener, + Router::new().fallback(|| async { StatusCode::SERVICE_UNAVAILABLE }), + ) + .await + .unwrap(); + }); + install_remote_fixture(&fixture, "metadata-runtime", &endpoint, true); + let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut update = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + update_remote_runtime_metadata_owned( + fixture.api.clone(), + WORKSPACE.into(), + "metadata-runtime".into(), + "updated metadata".into(), + endpoint, + ), + )); + assert!(futures::poll!(&mut update).is_pending()); + observed(waiting).await.unwrap(); // The real metadata SQL mutation is waiting. + runtime_progress_while_locked(&lock).await; + drop(update); + assert_eq!( + leases.load(Ordering::SeqCst), + 2, + "actual Host workflow and its accepted SQL both retain authority" + ); + lock.release(); + busy.resume(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + let saved = fixture + .store + .get_workspace_runtime_binding(WORKSPACE, "metadata-runtime") + .unwrap() + .unwrap(); + assert_eq!(saved.display_name, "updated metadata"); + let published = fixture + .api + .runtime_binding_expectations + .read() + .unwrap() + .get(&(WORKSPACE.into(), "metadata-runtime".into())) + .cloned() + .unwrap(); + assert_eq!( + published, saved, + "SQL completion must publish even after waiter cancellation" + ); + fixture + .api + .runtime_subscription_broker + .unregister_runtime("metadata-runtime"); + fixture.api.worker_projection.shutdown_async().await; + fixture.executor.drain().await.unwrap(); + server.abort(); + let _ = server.await; +} + +#[tokio::test(flavor = "current_thread")] +async fn runtime_metadata_reads_wait_off_tokio_and_db_and_retain_cancelled_host_ownership() { + for read in ["resources", "launch_options"] { + let fixture = Fixture::new().await; + let mut reply = HeldRuntimeReply::new(); + install_remote_fixture( + &fixture, + "summary-runtime", + &format!("http://{}", reply.address), + true, + ); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let api = fixture.api.clone(); + let task = tokio::spawn(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + async move { + if read == "resources" { + workspace_runtime_resources_response(&api, WORKSPACE) + .await + .map(|_| ()) + } else { + get_worker_launch_options(State(api)).await.map(|_| ()) + } + }, + )); + let request = observed(reply.entered.take().unwrap()).await.unwrap(); + assert!(request.contains("/v1/runtime"), "{request}"); + assert!( + !reply.upstream_released.load(Ordering::SeqCst), + "upstream watchdog released before Tokio could observe the request" + ); + let read_store = fixture.store.clone(); + observed( + fixture + .executor + .run(move || read_store.get_workspace_memory_settings(WORKSPACE)), + ) + .await + .unwrap(); + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; // timer contract only + assert!(!reply.upstream_released.load(Ordering::SeqCst)); + assert!(!task.is_finished()); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!( + leases.load(Ordering::SeqCst), + 1, + "cancel released actual Runtime summary owner" + ); + reply.finish(); + observed(fixture.host_executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + fixture.executor.drain().await.unwrap(); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn worker_discovery_sql_waits_off_tokio_and_keeps_admission_until_actual_cleanup() { + // Exercise the exact post-auth handler boundary: signed source verification + // has separate route coverage. Auth itself also awaits DB and would otherwise + // hide a handler's direct mutex acquisition until after the writer is released. + for cancel_waiter in [false, true] { + let fixture = Fixture::new().await; + let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let writer = fixture.store.clone(); + fixture + .executor + .dispatch(move || { + writer + .mark_api_token_used("missing-token", "2026-01-01T00:00:00Z") + .unwrap(); + }) + .unwrap(); + observed(waiting).await.unwrap(); // Actual SQL writer holds shared connection mutex. + let query = || server_api::WorkspaceWorkerDiscoveryQuery { + limit: Some(1), + cursor: None, + query: Some("missing-peer".into()), + }; + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let lifetime = Arc::new(CountJobLifetime(leases.clone())); + let mut discovery = Box::pin(crate::db_executor::with_operation_lifetime( + lifetime, + discover_workspace_workers(fixture.api.clone(), WORKSPACE.into(), query(), true), + )); + assert!( + futures::poll!(&mut discovery).is_pending(), + "discovery must queue, not synchronously wait for the SQL mutex" + ); + assert_eq!(leases.load(Ordering::SeqCst), 1); + runtime_progress_while_locked(&lock).await; + assert_eq!( + observed(fixture.host_executor.run(|| Ok::<_, Error>("Host control"))) + .await + .unwrap(), + "Host control" + ); + assert!(futures::poll!(&mut discovery).is_pending()); + let rejected = + discover_workspace_workers(fixture.api.clone(), WORKSPACE.into(), query(), true) + .await + .unwrap_err() + .into_response(); + let body = response_json(rejected, StatusCode::SERVICE_UNAVAILABLE).await; + assert!(body.to_string().contains("db_capacity_exceeded"), "{body}"); + let denied = + discover_workspace_workers(fixture.api.clone(), WORKSPACE.into(), query(), false) + .await + .unwrap_err() + .into_response(); + response_json(denied, StatusCode::FORBIDDEN).await; + // Failure cleanup never makes the progress assertions above true. + assert!(!lock.released.load(Ordering::SeqCst)); + if cancel_waiter { + drop(discovery); + assert_eq!( + leases.load(Ordering::SeqCst), + 1, + "cancel released accepted discovery authority" + ); + let rejected = + discover_workspace_workers(fixture.api.clone(), WORKSPACE.into(), query(), true) + .await + .unwrap_err() + .into_response(); + response_json(rejected, StatusCode::SERVICE_UNAVAILABLE).await; + lock.release(); + busy.resume(); + } else { + lock.release(); + busy.resume(); + let page = observed(discovery).await.unwrap(); + assert!( + page.workers.is_empty(), + "empty filtered projection must remain empty" + ); + assert!(page.next_cursor.is_none()); + } + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!( + leases.load(Ordering::SeqCst), + 0, + "actual discovery cleanup leaked its lease" + ); + fixture.host_executor.drain().await.unwrap(); + } +} diff --git a/crates/workspace-server/src/server_drive.rs b/crates/workspace-server/src/server_drive.rs index 71291f6a8..b055c3b1b 100644 --- a/crates/workspace-server/src/server_drive.rs +++ b/crates/workspace-server/src/server_drive.rs @@ -55,14 +55,13 @@ async fn blocking( mutation: bool, action: impl FnOnce() -> workspace_drive::Result + Send + 'static, ) -> DriveResult { - tokio::task::spawn_blocking(action) + crate::db_executor::DbExecutor::shared() + .run(move || Ok::<_, Error>(action())) .await - .map_err(|_| { - if mutation { - DriveApiError::new(DriveApiErrorCode::OutcomeUnknown) - } else { - failure(503) - } + .map_err(|error| match error { + Error::DbCapacityExceeded | Error::DbExecutorUnavailable => failure(503), + _ if mutation => DriveApiError::new(DriveApiErrorCode::OutcomeUnknown), + _ => failure(503), })? .map_err(|error| domain_error(error, mutation)) } diff --git a/crates/workspace-server/src/server_drive_grants.rs b/crates/workspace-server/src/server_drive_grants.rs index c593ad8e4..165435e7c 100644 --- a/crates/workspace-server/src/server_drive_grants.rs +++ b/crates/workspace-server/src/server_drive_grants.rs @@ -28,7 +28,10 @@ pub(super) fn pre_error(error: impl Into) -> DriveApiError { }) } fn mutation_error(error: Error) -> DriveApiError { - if matches!(error, Error::Sqlite(_) | Error::Store(_)) { + if matches!( + error, + Error::Sqlite(_) | Error::Store(_) | Error::DbOperationOutcomeUnknown + ) { fail(DriveApiErrorCode::OutcomeUnknown) } else { pre_error(error) @@ -65,8 +68,9 @@ pub(super) async fn authorized_drive( // incomplete, or inconsistent with its transport identity. let headers = current_worker_contract_headers(context) .map_err(|_| fail(DriveApiErrorCode::Denied))?; - let worker = current_worker_identity(api, workspace_id, &headers) - .map_err(|_| fail(DriveApiErrorCode::Denied))?; + let worker = authenticate_worker_mutation_source_owned(api, workspace_id, &headers) + .await + .map_err(pre_error)?; if context.worker_source.as_ref().is_some_and(|source| { source.runtime_id != worker.runtime_id || source.worker_id != worker.worker_id }) { @@ -101,23 +105,27 @@ pub(super) async fn authorized_drive( }; // Reject missing/read-only grants before upload body reception. This check // is admission only: every later operation rechecks under its own lock. - tokio::task::spawn_blocking(move || { - drive.check_authorized(write)?; - Ok::<_, workspace_drive::Error>(drive) - }) - .await - .map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))? - .map_err(|error| { - fail(match error { - workspace_drive::Error::Denied => DriveApiErrorCode::Denied, - workspace_drive::Error::Fenced | workspace_drive::Error::Conflict => { - DriveApiErrorCode::Conflict - } - workspace_drive::Error::NotFound => DriveApiErrorCode::NotFound, - workspace_drive::Error::Invalid(_) => DriveApiErrorCode::Invalid, - _ => DriveApiErrorCode::StorageUnavailable, + api.store + .db_executor() + .run(move || { + Ok::<_, Error>((|| { + drive.check_authorized(write)?; + Ok::<_, workspace_drive::Error>(drive) + })()) + }) + .await + .map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))? + .map_err(|error| { + fail(match error { + workspace_drive::Error::Denied => DriveApiErrorCode::Denied, + workspace_drive::Error::Fenced | workspace_drive::Error::Conflict => { + DriveApiErrorCode::Conflict + } + workspace_drive::Error::NotFound => DriveApiErrorCode::NotFound, + workspace_drive::Error::Invalid(_) => DriveApiErrorCode::Invalid, + _ => DriveApiErrorCode::StorageUnavailable, + }) }) - }) } pub(super) async fn create_grant( @@ -136,9 +144,14 @@ pub(super) async fn create_grant( } } let worker = RuntimeWorkerRef::new(&request.runtime_id, &request.worker_id); + let operation_api = api.clone(); let summary = api - .runtime - .worker(&worker) + .host_executors + .host() + .map_err(pre_error)? + .run(move || Ok::<_, Error>(operation_api.runtime.worker(&worker))) + .await + .map_err(pre_error)? .map_err(|_| fail(DriveApiErrorCode::NotFound))?; if summary.workspace.workspace_id.as_deref() != Some(workspace_id) { return Err(fail(DriveApiErrorCode::Denied)); @@ -156,9 +169,10 @@ pub(super) async fn create_grant( revoked_at: None, }; let store = api.store.clone(); - tokio::task::spawn_blocking(move || store.create_workspace_drive_grant(&grant)) + api.store + .db_executor() + .run(move || store.create_workspace_drive_grant(&grant)) .await - .map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))? .map_err(mutation_error) } @@ -179,13 +193,12 @@ pub(super) async fn revoke_grant( actor.account_id.clone(), ); let store = api.store.clone(); - tokio::task::spawn_blocking(move || { - store.revoke_workspace_drive_grant(&workspace, &grant, &actor) - }) - .await - .map_err(|_| fail(DriveApiErrorCode::OutcomeUnknown))? - .map_err(mutation_error)? - .ok_or_else(|| fail(DriveApiErrorCode::NotFound)) + api.store + .db_executor() + .run(move || store.revoke_workspace_drive_grant(&workspace, &grant, &actor)) + .await + .map_err(mutation_error)? + .ok_or_else(|| fail(DriveApiErrorCode::NotFound)) } fn validate_grant_id(id: &str) -> DriveResult<()> { @@ -216,12 +229,14 @@ pub(super) async fn list_grants( } let workspace = workspace_id.to_owned(); let store = api.store.clone(); - let mut grants = tokio::task::spawn_blocking(move || { - store.list_workspace_drive_grants(&workspace, limit + 1, query.after.as_deref()) - }) - .await - .map_err(|_| fail(DriveApiErrorCode::StorageUnavailable))? - .map_err(pre_error)?; + let mut grants = api + .store + .db_executor() + .run(move || { + store.list_workspace_drive_grants(&workspace, limit + 1, query.after.as_deref()) + }) + .await + .map_err(pre_error)?; let more = grants.len() > limit; grants.truncate(limit); let next_after = if more { diff --git a/crates/workspace-server/src/server_public_assignment_execution_tests.rs b/crates/workspace-server/src/server_public_assignment_execution_tests.rs new file mode 100644 index 000000000..b48394b97 --- /dev/null +++ b/crates/workspace-server/src/server_public_assignment_execution_tests.rs @@ -0,0 +1,120 @@ +//! Cross-lane composition regressions for T743's three new async DB callers. +//! Domain success/CAS/replay/rollback is covered by assignment_tracking_tests; +//! these exercise real generated handlers and their finite execution owners. +use super::*; + +struct Release(Option>); +impl Drop for Release { + fn drop(&mut self) { + self.0.take(); + } +} + +async fn queued_caller_keeps_owner( + method: &str, + suffix: &str, + body: serde_json::Value, + host: bool, +) { + let fixture = Fixture::new().await; + let executor = if host { + fixture.host_executor.clone() + } else { + fixture.executor.clone() + }; + let store = fixture.store.clone(); + let (entered, waiting) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + let mut release = Release(Some(release)); + let watchdog = Arc::new(AtomicBool::new(false)); + let expired = watchdog.clone(); + // Occupy the actual owner and connection deterministically, not via sleeps + // or a request that could still be waiting in its authentication stage. + executor + .dispatch(move || { + store + .with_conn(|_| { + let _ = entered.send(()); + if permit.recv_timeout(WATCHDOG).is_err() { + expired.store(true, Ordering::SeqCst); + } + Ok(()) + }) + .unwrap(); + }) + .unwrap(); + observed(waiting).await.unwrap(); + let path = format!("/api/w/{WORKSPACE}/tickets/T-missing/{suffix}"); + let request = || Fixture::request(method, &path, body.clone()); + let (lifetime, accepted) = admission_observation(); + let mut queued = Box::pin(crate::db_executor::with_operation_lifetime( + lifetime, + fixture.inner_router().oneshot(request()), + )); + assert!(futures::poll!(&mut queued).is_pending()); + observed(accepted).await.unwrap(); + let control = tokio::spawn(async { "independent control" }); + assert_eq!(observed(control).await.unwrap(), "independent control"); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; + assert!( + !watchdog.load(Ordering::SeqCst), + "synchronous caller stalled Tokio until the watchdog" + ); + drop(queued); + // Cancellation of the waiter cannot free the accepted queued SQL slot. + let rejected = observed(fixture.inner_router().oneshot(request())) + .await + .unwrap(); + let error = response_json(rejected, StatusCode::SERVICE_UNAVAILABLE).await; + assert!( + error["message"].as_str().unwrap().contains("capacity"), + "{error}" + ); + let mut drained = Box::pin(executor.drain()); + assert!(futures::poll!(&mut drained).is_pending()); + release.0.take().unwrap().send(()).unwrap(); + observed(drained).await.unwrap(); + // The accepted request finishes its real lookup after cancellation; the + // normal domain error and reusable capacity survive without SQL resending. + let after = observed(fixture.inner_router().oneshot(request())) + .await + .unwrap(); + response_json(after, StatusCode::NOT_FOUND).await; + observed(executor.drain()).await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn assignments_page_uses_finite_db_owner_and_survives_cancel() { + queued_caller_keeps_owner( + "GET", + "assignment-tracking/assignments", + serde_json::json!({}), + false, + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn diagnostics_page_uses_finite_db_owner_and_survives_cancel() { + queued_caller_keeps_owner( + "GET", + "assignment-diagnostics?assignment_id=missing", + serde_json::json!({}), + false, + ) + .await; +} + +#[tokio::test(flavor = "current_thread")] +async fn answer_uses_finite_host_owner_and_survives_cancel() { + queued_caller_keeps_owner( + "POST", + "assignment-answer", + serde_json::json!({ + "assignment_id":"missing", "expected_last_event_id":"missing", + "operation_key":"answer-probe", "body":"continuing input" + }), + true, + ) + .await; +} diff --git a/crates/workspace-server/src/server_residual_execution_tests.rs b/crates/workspace-server/src/server_residual_execution_tests.rs new file mode 100644 index 000000000..6cf3759fa --- /dev/null +++ b/crates/workspace-server/src/server_residual_execution_tests.rs @@ -0,0 +1,1381 @@ +use super::*; + +fn registry_record(worker: RuntimeWorkerRef) -> WorkerRegistryRecord { + WorkerRegistryRecord { + workspace_id: WORKSPACE.into(), + worker, + display_name: "current stored label".into(), + profile: Some("builtin:coder".into()), + retention_state: "normal".into(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + } +} + +fn assigned_ticket(fixture: &Fixture) -> String { + let record = registry_record(RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + Uuid::now_v7().to_string(), + )); + fixture.store.upsert_worker_registry(&record).unwrap(); + let mut input = ticket::NewTicket::new("cancel this implementation"); + input.workflow_state = Some(TicketState::Open); + let ticket = browser_ticket_backend(&fixture.api) + .unwrap() + .create(input) + .unwrap(); + fixture + .store + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { + workspace_id: WORKSPACE.into(), + ticket_id: ticket.id.clone(), + assignment_id: "current-coder".into(), + worker: record.worker, + assigned_by: "fixture-user".into(), + assigned_at: "2026-01-01T00:00:00Z".into(), + }, + None, + "assign-event", + "assign-operation", + false, + ) + .unwrap(); + ticket.id +} + +async fn fixture() -> Fixture { + let fixture = Fixture::new().await; + fixture.api.worker_projection.shutdown_async().await; + observed(fixture.executor.drain()).await.unwrap(); + fixture +} + +fn cancel_request() -> serde_json::Value { + serde_json::json!({ + "operation_id": "cancel-operation", "assignment_id": "current-coder", "reason": "use the corrected design" + }) +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_ticket_implementation_keeps_assignment_commit_owned() { + let fixture = fixture().await; + let ticket = assigned_ticket(&fixture); + let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut response = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + fixture.inner_router().oneshot(Fixture::request( + "POST", + &format!("/api/w/{WORKSPACE}/tickets/{ticket}/implementation-cancellations"), + cancel_request(), + )), + )); + assert!( + futures::poll!(&mut response).is_pending(), + "cancellation must admit an owned stage, not run SQL on Tokio" + ); + observed(waiting).await.unwrap(); // Actual cancellation transaction, after Runtime/session cleanup. + runtime_progress_while_locked(&lock).await; + drop(response); + assert_eq!( + leases.load(Ordering::SeqCst), + 1, + "actual mixed cancellation must keep its authority after the waiter drops" + ); + lock.release(); + busy.resume(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + assert!( + fixture + .store + .get_active_ticket_worker_assignment(WORKSPACE, &ticket) + .unwrap() + .is_none() + ); + assert!( + fixture + .store + .get_ticket_assignment_operation(WORKSPACE, "cancel-operation") + .unwrap() + .is_some() + ); + let detail = browser_ticket_detail(&fixture.api, &ticket).unwrap().0; + assert!(detail.current_worker.is_none()); + assert_eq!(detail.state.as_str(), TicketState::Open.as_str()); +} + +async fn saturated_host(fixture: &Fixture) -> Vec> { + let mut releases = Vec::new(); + let (started, running) = oneshot::channel(); + let (release, permit) = mpsc::channel::<()>(); + releases.push(release); + fixture + .host_executor + .dispatch(move || { + let _ = started.send(()); + let _ = permit.recv_timeout(WATCHDOG); + }) + .unwrap(); + observed(running).await.unwrap(); + let (release, permit) = mpsc::channel::<()>(); + releases.push(release); + fixture + .host_executor + .dispatch(move || { + let _ = permit.recv_timeout(WATCHDOG); + }) + .unwrap(); + releases +} + +#[tokio::test(flavor = "current_thread")] +async fn cancellation_host_overload_leaves_the_coder_assignment_unchanged() { + let fixture = fixture().await; + let ticket = assigned_ticket(&fixture); + let before = fixture + .store + .get_active_ticket_worker_assignment(WORKSPACE, &ticket) + .unwrap() + .unwrap(); + let releases = saturated_host(&fixture).await; + let body = response_json( + observed(fixture.inner_router().oneshot(Fixture::request( + "POST", + &format!("/api/w/{WORKSPACE}/tickets/{ticket}/implementation-cancellations"), + cancel_request(), + ))) + .await + .unwrap(), + StatusCode::SERVICE_UNAVAILABLE, + ) + .await; + assert!(body.to_string().contains("db_capacity_exceeded"), "{body}"); + assert_eq!( + fixture + .store + .get_active_ticket_worker_assignment(WORKSPACE, &ticket) + .unwrap() + .unwrap(), + before + ); + assert!( + fixture + .store + .get_ticket_assignment_operation(WORKSPACE, "cancel-operation") + .unwrap() + .is_none() + ); + drop(releases); + observed(fixture.host_executor.drain()).await.unwrap(); +} + +fn seed_unrepaired_mr(fixture: &Fixture) { + let ticket = browser_ticket_backend(&fixture.api) + .unwrap() + .create(ticket::NewTicket::new("repair source selector")) + .unwrap(); + fixture + .store + .upsert_repository(&crate::store::RepositoryRecord { + workspace_id: WORKSPACE.into(), + repository_id: "fixture-repo".into(), + repository_key: "fixture-repo".into(), + kind: "git".into(), + provider: Some("git".into()), + source: server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, + uri: fixture + ._dir + .path() + .join("unused-repository") + .to_string_lossy() + .into_owned(), + }, + default_ref: Some("develop".into()), + source_fingerprint: "sha256:fixture".into(), + observed_status: server_api::RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: "2026-01-01T00:00:00Z".into(), + updated_at: "2026-01-01T00:00:00Z".into(), + }) + .unwrap(); + // Persist the external unrepaired-selector shape. No active assignment: + // the real handler must reach its assignment read, then reject without provider dispatch. + let conn = Connection::open(&fixture.api.config.database_path).unwrap(); + conn.execute("INSERT INTO merge_requests VALUES (?1,'repair-fixture','fixture-repo','open',NULL,'develop',?2,?2)", rusqlite::params![WORKSPACE, "2026-01-01T00:00:00Z"]).unwrap(); + conn.execute("INSERT INTO merge_request_ticket_relations VALUES (?1,'repair-fixture',?2,'implements',?3)", rusqlite::params![WORKSPACE, ticket.id, "2026-01-01T00:00:00Z"]).unwrap(); +} + +fn repair_headers() -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + "cookie", + "db_execution_session=fixture-browser-token" + .parse() + .unwrap(), + ); + headers +} + +fn repair_input() -> Json { + Json(server_api::RepairMergeRequestSelectorRequest { + selector_from: "work/fixed-selector".into(), + reason: "repair the imported selector".into(), + explicit_confirmation: true, + }) +} + +#[tokio::test(flavor = "current_thread")] +async fn authenticated_selector_repair_waits_off_tokio_and_keeps_cancelled_host_ownership() { + let fixture = fixture().await; + seed_unrepaired_mr(&fixture); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut repair = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + scoped_repair_merge_request_selector( + State(fixture.api.clone()), + repair_headers(), + AxumPath((WORKSPACE.into(), "repair-fixture".into())), + repair_input(), + ), + )); + assert!(futures::poll!(&mut repair).is_pending()); // Browser authentication DB stage only. + observed(fixture.executor.drain()).await.unwrap(); + let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let writer = fixture.store.clone(); + fixture + .executor + .dispatch(move || { + writer + .mark_api_token_used("missing", "2026-01-01T00:00:00Z") + .unwrap(); + }) + .unwrap(); + observed(waiting).await.unwrap(); + assert!( + futures::poll!(&mut repair).is_pending(), + "post-auth selector repair must not wait on the connection mutex on Tokio" + ); + runtime_progress_while_locked(&lock).await; + drop(repair); + assert_eq!( + leases.load(Ordering::SeqCst), + 1, + "actual repair stage must retain authority after cancellation" + ); + lock.release(); + busy.resume(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + let mr = merge_request_store(&fixture.api, WORKSPACE) + .unwrap() + .get_by_id(WORKSPACE, "repair-fixture") + .unwrap(); + assert!(mr.selector_from.is_none()); + assert!( + merge_request_store(&fixture.api, WORKSPACE) + .unwrap() + .thread_page_by_id(WORKSPACE, "repair-fixture", None, 10) + .unwrap() + .is_empty() + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn selector_repair_host_overload_preserves_confirmation_and_no_side_effects() { + let fixture = fixture().await; + seed_unrepaired_mr(&fixture); + let releases = saturated_host(&fixture).await; + let error = scoped_repair_merge_request_selector( + State(fixture.api.clone()), + repair_headers(), + AxumPath((WORKSPACE.into(), "repair-fixture".into())), + repair_input(), + ) + .await + .unwrap_err(); + let body = response_json(error.into_response(), StatusCode::SERVICE_UNAVAILABLE).await; + assert!(body.to_string().contains("db_capacity_exceeded"), "{body}"); + let mut input = repair_input(); + input.0.explicit_confirmation = false; + let error = scoped_repair_merge_request_selector( + State(fixture.api.clone()), + repair_headers(), + AxumPath((WORKSPACE.into(), "repair-fixture".into())), + input, + ) + .await + .unwrap_err(); + assert!( + !matches!(error.error, Error::DbCapacityExceeded), + "browser confirmation denial must precede Host admission" + ); + let mr = merge_request_store(&fixture.api, WORKSPACE) + .unwrap() + .get_by_id(WORKSPACE, "repair-fixture") + .unwrap(); + assert!(mr.selector_from.is_none()); + drop(releases); + observed(fixture.host_executor.drain()).await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn post_restore_projection_waits_off_tokio_and_preserves_historical_results() { + let fixture = fixture().await; + let record = registry_record(RuntimeWorkerRef::new( + EMBEDDED_WORKER_RUNTIME_ID, + Uuid::now_v7().to_string(), + )); + fixture.store.upsert_worker_registry(&record).unwrap(); + let mut historical = worker_summary_from_registry(&record); + historical.label = "historical label".into(); + historical.state = "paused".into(); + let result = || crate::hosts::InternalWorkerRestoreResult { + state: server_api::WorkerRestoreState::Rejected, + worker: Some(historical.clone()), + diagnostics: Vec::new(), + }; + let (mut busy, waiting) = BusyObservation::install_store(fixture.store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let writer = fixture.store.clone(); + fixture + .executor + .dispatch(move || { + writer + .mark_api_token_used("missing", "2026-01-01T00:00:00Z") + .unwrap(); + }) + .unwrap(); + observed(waiting).await.unwrap(); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut projection = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + project_worker_restore_response_owned( + fixture.api.clone(), + record.worker.runtime_id.clone(), + record.worker.worker_id.clone(), + result(), + ), + )); + assert!( + futures::poll!(&mut projection).is_pending(), + "stored projection must await a whole DB stage after Host restore returns" + ); + runtime_progress_while_locked(&lock).await; + assert_eq!( + observed(fixture.host_executor.run(|| Ok::<_, Error>("control"))) + .await + .unwrap(), + "control" + ); + let error = project_worker_restore_response_owned( + fixture.api.clone(), + record.worker.runtime_id.clone(), + record.worker.worker_id.clone(), + result(), + ) + .await + .unwrap_err(); + response_json(error.into_response(), StatusCode::SERVICE_UNAVAILABLE).await; + let Json(no_worker) = project_worker_restore_response_owned( + fixture.api.clone(), + record.worker.runtime_id.clone(), + record.worker.worker_id.clone(), + crate::hosts::InternalWorkerRestoreResult { + state: server_api::WorkerRestoreState::Rejected, + worker: None, + diagnostics: Vec::new(), + }, + ) + .await + .unwrap(); + assert_eq!( + no_worker.result.state, + server_api::WorkerRestoreState::Rejected + ); + drop(projection); + assert_eq!(leases.load(Ordering::SeqCst), 1); + lock.release(); + busy.resume(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + let Json(response) = project_worker_restore_response_owned( + fixture.api.clone(), + record.worker.runtime_id.clone(), + record.worker.worker_id.clone(), + result(), + ) + .await + .unwrap(); + assert_eq!( + response.result.state, + server_api::WorkerRestoreState::Rejected + ); + let worker = response.result.worker.unwrap(); + assert_eq!(worker.label, record.display_name); + assert_eq!( + worker.state, "paused", + "a historical receipt is not a new Runtime observation" + ); + assert_eq!( + fixture + .store + .get_worker_registry(WORKSPACE, &record.worker) + .unwrap() + .unwrap(), + record, + "response projection must not write a new observation" + ); + observed(fixture.executor.drain()).await.unwrap(); +} + +#[tokio::test(flavor = "current_thread")] +async fn ticket_source_ref_reads_and_ready_publication_leave_sql_and_tokio_free() { + for operation in ["detail", "query", "ready"] { + let repository_dir = tempfile::tempdir().unwrap(); + let source = server_api::RepositorySource { + kind: server_api::RepositorySourceKind::LocalPath, + uri: repository_dir.path().to_string_lossy().into_owned(), + }; + let fixture = Fixture::with_repositories(vec![ConfiguredRepository { + id: "fixture-repo".into(), + repository_key: "fixture-repo".into(), + provider: "git".into(), + source_fingerprint: crate::repository_source::repository_source_fingerprint(&source), + source, + observed_status: server_api::RepositoryObservedStatus::Unverified, + observed_at: None, + path: Some(repository_dir.path().into()), + default_selector: Some("develop".into()), + }]) + .await; + fixture.api.worker_projection.shutdown_async().await; + observed(fixture.executor.drain()).await.unwrap(); + let mut reply = HeldRuntimeReply::for_path("/v1/repository-refs/observe"); + // Narrow Runtime HTTP/provider placement fixture, not a signed authority + // or complete Git/Runtime E2E test. Existing signed routes test that fence. + install_remote_fixture( + &fixture, + "ref-runtime", + &format!("http://{}", reply.address), + false, + ); + let worker = RuntimeWorkerRef::new("ref-runtime", Uuid::now_v7().to_string()); + fixture + .store + .upsert_worker_registry(®istry_record(worker.clone())) + .unwrap(); + let mut input = ticket::NewTicket::new("source ref projection"); + input.workflow_state = Some(TicketState::Open); + let ticket = browser_ticket_backend(&fixture.api) + .unwrap() + .create(input) + .unwrap(); + fixture + .store + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { + workspace_id: WORKSPACE.into(), + ticket_id: ticket.id.clone(), + assignment_id: "ref-assignment".into(), + worker: worker.clone(), + assigned_by: "fixture-user".into(), + assigned_at: "2026-01-01T00:00:00Z".into(), + }, + None, + "ref-event", + "ref-operation", + false, + ) + .unwrap(); + merge_request_store(&fixture.api, WORKSPACE) + .unwrap() + .open_merge_request(merge_request::OpenMergeRequest { + merge_request_id: "ref-mr".into(), + ticket_id: ticket.id.clone(), + repository_id: "fixture-repo".into(), + selector_from: "work/source".into(), + selector_to: "develop".into(), + summary: String::new(), + auth: merge_request::MergeRequestAuth { + workspace_id: WORKSPACE.into(), + repository_id: "fixture-repo".into(), + runtime_id: worker.runtime_id, + worker_id: worker.worker_id, + assignment_id: "ref-assignment".into(), + }, + now: Utc::now(), + }) + .unwrap(); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let api = fixture.api.clone(); + let ticket_id = ticket.id.clone(); + let mut response = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + async move { + match operation { + "detail" => scoped_get_ticket( + State(api), + AxumPath(ScopedRecordPath { + workspace_id: WORKSPACE.into(), + id: ticket_id, + }), + ) + .await + .map(|_| ()), + "query" => scoped_query_tickets( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: WORKSPACE.into(), + }), + Json(TicketQueryRequest::default()), + ) + .await + .map(|_| ()), + _ => scoped_mark_ticket_ready_from_browser( + State(api), + HeaderMap::new(), + AxumPath(ScopedRecordPath { + workspace_id: WORKSPACE.into(), + id: ticket_id, + }), + Json(InternalTicketMarkReadyRequest { + operation_key: "ready-operation".into(), + reason: None, + intake_summary: None, + }), + ) + .await + .map(|_| ()), + } + }, + )); + assert!(futures::poll!(&mut response).is_pending()); + // Ready reaches the source POST only after mutation and best-effort + // notification. Bound this multi-stage setup with the fixture watchdog, + // not the shorter budget used below to prove timer/independent control + // progress while the actual provider request is held. + let request = tokio::select! { + request = tokio::time::timeout(WATCHDOG, reply.entered.take().unwrap()) => request.expect("source observation setup did not reach the provider").unwrap(), + result = &mut response => panic!("{operation} returned before the held source observation: {result:?}"), + }; + assert!( + request.contains("/v1/repository-refs/observe"), + "{operation}: {request}" + ); + let store = fixture.store.clone(); + observed( + fixture + .executor + .run(move || store.get_workspace_memory_settings(WORKSPACE)), + ) + .await + .unwrap(); + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; + assert!( + !reply.upstream_released.load(Ordering::SeqCst), + "watchdog release is not provider progress" + ); + if operation == "ready" { + let saved = browser_ticket_backend(&fixture.api) + .unwrap() + .show(TicketIdOrSlug::Id(ticket.id)) + .unwrap(); + assert_eq!( + saved.meta.readiness.as_deref(), + Some("ready"), + "Ready mutation must be durable before the held detail source observation" + ); + assert_eq!( + saved + .events + .iter() + .filter( + |event| event.attributes.get("operation_key").map(String::as_str) + == Some("ready-operation") + ) + .count(), + 1 + ); + assert_eq!( + saved.meta.workflow_state, + TicketState::Open, + "Ready must commit before detail publication" + ); + } + drop(response); + assert_eq!( + leases.load(Ordering::SeqCst), + 1, + "{operation}: cancelled provider stage lost actual ownership" + ); + fixture.host_executor.dispatch(|| {}).unwrap(); // Remaining finite queued slot. + let error = scoped_get_ticket( + State(fixture.api.clone()), + AxumPath(ScopedRecordPath { + workspace_id: WORKSPACE.into(), + id: "unused".into(), + }), + ) + .await + .unwrap_err(); + response_json(error.into_response(), StatusCode::SERVICE_UNAVAILABLE).await; + reply.finish(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn worker_control_source_lookup_waits_off_tokio_and_retains_finite_host_ownership() { + let fixture = fixture().await; + let mut reply = HeldRuntimeReply::new(); + install_remote_fixture( + &fixture, + "source-runtime", + &format!("http://{}", reply.address), + false, + ); + let source = RuntimeWorkerRef::new("source-runtime", Uuid::now_v7().to_string()); + let mut headers = HeaderMap::new(); + headers.insert("x-yoi-runtime-id", source.runtime_id.parse().unwrap()); + headers.insert("x-yoi-worker-id", source.worker_id.parse().unwrap()); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let request = || { + Json(server_api::RuntimeWorkerLifecycleRequest { + reason: None, + ticket_assignment: None, + }) + }; + let path = || { + AxumPath(ScopedRuntimeWorkerPath { + workspace_id: WORKSPACE.into(), + worker: source.clone(), + }) + }; + let mut control = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + cancel_known_worker( + State(fixture.api.clone()), + path(), + headers.clone(), + request(), + ), + )); + assert!( + futures::poll!(&mut control).is_pending(), + "control source lookup must not synchronously join Runtime HTTP on Tokio" + ); + let request_line = observed(reply.entered.take().unwrap()).await.unwrap(); + assert!(request_line.contains("/v1/workers/"), "{request_line}"); + let store = fixture.store.clone(); + observed( + fixture + .executor + .run(move || store.get_workspace_memory_settings(WORKSPACE)), + ) + .await + .unwrap(); + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; + assert!(!reply.upstream_released.load(Ordering::SeqCst)); + drop(control); + assert_eq!(leases.load(Ordering::SeqCst), 1); + fixture.host_executor.dispatch(|| {}).unwrap(); + let error = cancel_known_worker(State(fixture.api.clone()), path(), headers, request()) + .await + .unwrap_err(); + let body = response_json(error.into_response(), StatusCode::SERVICE_UNAVAILABLE).await; + assert!( + body.to_string().contains("db_capacity_exceeded"), + "Host overload is availability, not identity denial: {body}" + ); + reply.finish(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); +} + +#[tokio::test(flavor = "current_thread")] +async fn ticket_tracking_and_case_authority_runtime_lookup_leave_db_and_tokio_free() { + for operation in ["tracking", "case"] { + let fixture = fixture().await; + let ticket = browser_ticket_backend(&fixture.api) + .unwrap() + .create(ticket::NewTicket::new("decision authority")) + .unwrap(); + let mut reply = HeldRuntimeReply::new(); + install_remote_fixture( + &fixture, + "orchestrator-runtime", + &format!("http://{}", reply.address), + false, + ); + let memory = fixture + .store + .get_workspace_memory_settings(WORKSPACE) + .unwrap(); + let reservation = fixture + .store + .reserve_worker_create( + WORKSPACE, + "orchestrator-runtime", + "orch-create", + "orch-create-fingerprint", + Some(crate::hosts::WORKSPACE_ORCHESTRATOR_SINGLETON_KEY), + &memory, + ) + .unwrap(); + let source = + RuntimeWorkerRef::new("orchestrator-runtime", reservation.worker_id.to_string()); + fixture + .store + .upsert_worker_registry(®istry_record(source.clone())) + .unwrap(); + fixture + .store + .complete_worker_create_reservation(WORKSPACE, reservation.worker_id) + .unwrap(); + let context = server_api::ServerRequestContext { + actor: None, + worker_source: None, + runtime_source: Some(server_api::ServerRuntimeSource { + authentication_binding_id: "verified-fixture-binding".into(), + runtime_id: source.runtime_id.clone(), + worker_id: Some(source.worker_id.clone()), + }), + origin: None, + transport_headers: Vec::new(), + }; + let backend = browser_ticket_backend(&fixture.api).unwrap(); + let before = backend.show(TicketIdOrSlug::Id(ticket.id.clone())).unwrap(); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let api = fixture.api.clone(); + let ticket_id = ticket.id.clone(); + let mut decision = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + async { + if operation == "tracking" { + scoped_ticket_assignment_tracking_update_with_context( + State(api), + HeaderMap::new(), + &context, + AxumPath((WORKSPACE.into(), ticket_id)), + Json(ticket::TicketAssignmentTrackingUpdate { + assignment_id: "no-delegation".into(), + expected_last_event_id: "baseline".into(), + operation_key: "track-operation".into(), + tracking: ticket::TicketAssignmentTracking { + status: ticket::TicketAssignmentTrackingStatus::Inprogress, + waiting_reason: None, + progress: None, + next_action: None, + result: None, + }, + }), + ) + .await + .map(|_| ()) + } else { + apply_ticket_case_update( + State(api), + Some(source), + None, + AxumPath((WORKSPACE.into(), ticket_id)), + Json(ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&before), + expected_state: before.meta.workflow_state, + expected_closure_operation_key: None, + operation_key: "case-operation".into(), + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: "check current authority".into(), + references: Vec::new(), + author: None, + }), + ) + .await + .map(|_| ()) + } + }, + )); + assert!(futures::poll!(&mut decision).is_pending()); + let request = observed(reply.entered.take().unwrap()).await.unwrap(); + assert!(request.contains("/v1/workers/"), "{operation}: {request}"); + let store = fixture.store.clone(); + observed( + fixture + .executor + .run(move || store.get_workspace_memory_settings(WORKSPACE)), + ) + .await + .unwrap(); + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; + assert!(!reply.upstream_released.load(Ordering::SeqCst)); + drop(decision); + assert_eq!(leases.load(Ordering::SeqCst), 1); + reply.finish(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + assert_eq!( + backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(), + before, + "failed live authority must not make a decision" + ); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn cancelled_feature_shutdown_keeps_sql_close_owned_without_blocking_control() { + let fixture = fixture().await; + let (database, _) = fixture.feature_database(); + let mutation = database.clone(); + let (entered, waiting) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + let released = Arc::new(AtomicBool::new(false)); + let flag = released.clone(); + let thread = std::thread::spawn(move || { + mutation + .with_connection(|conn| { + conn.execute_batch("BEGIN IMMEDIATE; CREATE TABLE close_owner_probe(id INTEGER)")?; + let _ = entered.send(()); + let _ = permit.recv_timeout(WATCHDOG); + flag.store(true, Ordering::SeqCst); + conn.execute_batch("ROLLBACK")?; + Ok(()) + }) + .unwrap(); + }); + let mut lock = WriteLock { + release: Some(release), + thread: Some(thread), + released, + }; + observed(waiting).await.unwrap(); // actual SQL transaction owns the Feature connection + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut close = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + shutdown_workspace_feature_storage(&fixture.api), + )); + assert!( + futures::poll!(&mut close).is_pending(), + "Feature connection close must not wait on Tokio" + ); + runtime_progress_while_locked(&lock).await; + assert_eq!( + observed(fixture.host_executor.run(|| Ok::<_, Error>("control"))) + .await + .unwrap(), + "control" + ); + fixture.executor.dispatch(|| {}).unwrap(); + assert!(matches!( + shutdown_workspace_feature_storage(&fixture.api) + .await + .unwrap_err(), + Error::DbCapacityExceeded + )); + drop(close); + assert_eq!(leases.load(Ordering::SeqCst), 1); + lock.release(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + assert!( + database.with_connection(|_| Ok(())).is_err(), + "accepted shutdown must fence later use" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn session_workdir_and_upload_callers_keep_provider_waits_off_sql_and_tokio() { + use server_api::ServerApi; + for operation in ["session", "history", "attachment", "workdirs", "upload"] { + let fixture = fixture().await; + let mut reply = HeldRuntimeReply::new(); + install_remote_fixture( + &fixture, + "caller-runtime", + &format!("http://{}", reply.address), + false, + ); + let worker_id = Uuid::now_v7().to_string(); + fixture + .store + .upsert_worker_registry(®istry_record(RuntimeWorkerRef::new( + "caller-runtime", + &worker_id, + ))) + .unwrap(); + let actor = RequestActor { + user_id: "fixture-user".into(), + account_id: format!("account-{WORKSPACE}"), + handle: "fixture-user".into(), + display_name: "Fixture user".into(), + auth_method: ActorAuthMethod::BrowserSession, + }; + fixture.api.attachment_upload_grants.lock().unwrap().insert( + "caller-upload".into(), + AttachmentUploadGrant { + workspace_id: WORKSPACE.into(), + runtime_id: "caller-runtime".into(), + worker_id: worker_id.clone(), + account_id: actor.account_id.clone(), + file_name: "notes.txt".into(), + media_type: "text/plain".into(), + expires_at_ms: u64::MAX, + state: AttachmentUploadGrantState::Pending, + }, + ); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let api = fixture.api.clone(); + let task = tokio::spawn(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + async move { + let service = ServerApiContractService::Workspace(api.clone()); + match operation { + "session" => { + let _ = service + .worker_session(WORKSPACE.into(), "caller-runtime".into(), worker_id) + .await; + } + "history" => { + let _ = service + .worker_session_history( + WORKSPACE.into(), + "caller-runtime".into(), + worker_id, + server_api::WorkspaceWorkerSessionHistoryQuery { + cursor: None, + limit: Some(1), + }, + ) + .await; + } + "attachment" => { + let _ = scoped_worker_session_attachment( + State(api), + AxumPath(( + WORKSPACE.into(), + "caller-runtime".into(), + worker_id, + "session".into(), + "attachment".into(), + )), + Extension(actor), + ) + .await; + } + "workdirs" => { + let _ = scoped_list_working_directories( + State(api), + AxumPath(ScopedWorkspacePath { + workspace_id: WORKSPACE.into(), + }), + ) + .await; + } + "upload" => { + let _ = upload_runtime_worker_file_contract( + api, + actor, + ScopedAttachmentUploadPath { + workspace_id: WORKSPACE.into(), + runtime_id: "caller-runtime".into(), + worker_id, + upload_id: "caller-upload".into(), + }, + b"notes", + ) + .await; + } + _ => unreachable!(), + } + }, + )); + let request = observed(reply.entered.take().unwrap()).await.unwrap(); + assert!(request.contains("/v1/"), "{operation}: {request}"); + assert!( + !reply.upstream_released.load(Ordering::SeqCst), + "{operation}: watchdog release is not executor progress" + ); + let store = fixture.store.clone(); + observed( + fixture + .executor + .run(move || store.get_workspace_memory_settings(WORKSPACE)), + ) + .await + .unwrap(); + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; + let mut queued = Box::pin(fixture.host_executor.run(|| Ok::<_, Error>(()))); + assert!(futures::poll!(&mut queued).is_pending()); + let error = scoped_list_working_directories( + State(fixture.api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: WORKSPACE.into(), + }), + ) + .await + .unwrap_err(); + response_json(error.into_response(), StatusCode::SERVICE_UNAVAILABLE).await; + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!( + leases.load(Ordering::SeqCst), + 1, + "{operation}: lost provider owner" + ); + reply.finish(); + observed(queued).await.unwrap(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!( + leases.load(Ordering::SeqCst), + 0, + "{operation}: retained owner after completion" + ); + if operation == "upload" { + assert!( + matches!( + fixture + .api + .attachment_upload_grants + .lock() + .unwrap() + .get("caller-upload") + .unwrap() + .state, + AttachmentUploadGrantState::Pending + ), + "failed Runtime lookup must finish the accepted grant claim even with a cancelled waiter" + ); + } + } +} + +#[tokio::test(flavor = "current_thread")] +async fn repository_create_keeps_cancelled_sql_commit_owned_and_replays_one_intent() { + let fixture = fixture().await; + let (mut busy, waiting) = BusyObservation::install_store(fixture.api.config_store.clone()); + let mut lock = WriteLock::acquire(fixture.api.config.database_path.clone()); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let request = || { + Fixture::request( + "POST", + &format!("/api/w/{WORKSPACE}/repositories"), + serde_json::json!({ "repository_key": "owned-repository", "source": "https://example.invalid/repo.git", "default_ref": "main" }), + ) + }; + let mut response = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + fixture.inner_router().oneshot(request()), + )); + tokio::select! { + result = &mut response => panic!("repository must wait for its write transaction: {:?}", result.unwrap().status()), + reached = observed(waiting) => reached.unwrap(), + } + runtime_progress_while_locked(&lock).await; + assert_eq!( + observed(fixture.host_executor.run(|| Ok::<_, Error>("control"))) + .await + .unwrap(), + "control" + ); + drop(response); + assert_eq!(leases.load(Ordering::SeqCst), 1); + lock.release(); + busy.resume(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + let rows = fixture.store.list_repositories(WORKSPACE).unwrap(); + assert_eq!( + rows.iter() + .filter(|record| record.repository_key == "owned-repository") + .count(), + 1 + ); + let replay = observed(fixture.inner_router().oneshot(request())) + .await + .unwrap(); + let body = response_json(replay, StatusCode::OK).await; + assert_eq!(body["replayed"], true); + assert_eq!( + fixture.store.list_repositories(WORKSPACE).unwrap().len(), + rows.len() + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn drive_and_config_preflight_keep_live_authority_owned_and_report_overload_as_availability() +{ + async fn preflight( + api: &WorkspaceApi, + context: &server_api::ServerRequestContext, + operation: &str, + ) -> u16 { + match operation { + "config" => { + workspace_config::identity(api, context, WORKSPACE) + .await + .err() + .unwrap() + .status + } + "drive" => api_macros::HttpError::status_code( + &drive_grants::authorized_drive(api, context, WORKSPACE, false) + .await + .err() + .unwrap(), + ), + "grant" => api_macros::HttpError::status_code( + &drive_grants::create_grant( + api, + context, + WORKSPACE, + server_api::DriveGrantCreateRequest { + runtime_id: "preflight-runtime".into(), + worker_id: "target-worker".into(), + access: server_api::DriveAccess::ReadOnly, + }, + ) + .await + .err() + .unwrap(), + ), + _ => unreachable!(), + } + } + for operation in ["config", "drive", "grant"] { + let fixture = fixture().await; + let mut reply = HeldRuntimeReply::new(); + install_remote_fixture( + &fixture, + "preflight-runtime", + &format!("http://{}", reply.address), + false, + ); + let context = server_api::ServerRequestContext { + actor: if operation == "grant" { + Some(RequestActor { + user_id: "fixture-user".into(), + account_id: format!("account-{WORKSPACE}"), + handle: "fixture-user".into(), + display_name: "Fixture user".into(), + auth_method: ActorAuthMethod::BrowserSession, + }) + } else { + None + }, + worker_source: None, + runtime_source: if operation == "grant" { + None + } else { + Some(server_api::ServerRuntimeSource { + authentication_binding_id: "fixture-binding".into(), + runtime_id: "preflight-runtime".into(), + worker_id: Some("source-worker".into()), + }) + }, + origin: None, + transport_headers: Vec::new(), + }; + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let api = fixture.api.clone(); + let request_context = context.clone(); + let task = tokio::spawn(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + async move { preflight(&api, &request_context, operation).await }, + )); + let request = observed(reply.entered.take().unwrap()).await.unwrap(); + assert!(request.contains("/v1/workers/"), "{operation}: {request}"); + let store = fixture.store.clone(); + observed( + fixture + .executor + .run(move || store.get_workspace_memory_settings(WORKSPACE)), + ) + .await + .unwrap(); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + assert!(!reply.upstream_released.load(Ordering::SeqCst)); + task.abort(); + assert!(task.await.unwrap_err().is_cancelled()); + assert_eq!(leases.load(Ordering::SeqCst), 1); + fixture.host_executor.dispatch(|| {}).unwrap(); + assert_eq!( + observed(preflight(&fixture.api, &context, operation)).await, + 503, + "{operation}: overload must not be authority denial" + ); + reply.finish(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + } +} + +#[tokio::test(flavor = "current_thread")] +async fn ui_config_commit_publishes_after_commit_without_holding_the_sql_lane() { + let fixture = fixture().await; + let current = fixture + .api + .config_store + .load_workspace_config(WORKSPACE) + .unwrap() + .unwrap(); + let mut reply = HeldRuntimeReply::new(); + install_remote_fixture( + &fixture, + "prompt-runtime", + &format!("http://{}", reply.address), + false, + ); + let leases = Arc::new(std::sync::atomic::AtomicUsize::new(0)); + let mut commit = Box::pin(crate::db_executor::with_operation_lifetime( + Arc::new(CountJobLifetime(leases.clone())), + scoped_commit_workspace_config_tree( + State(fixture.api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: WORKSPACE.into(), + }), + Json(server_api::ConfigCommitRequest { + base_digest: current.snapshot.digest.clone(), + entrypoints: current + .contract + .entrypoints + .iter() + .map(ToString::to_string) + .collect(), + changes: vec![server_api::ConfigTreeChange::Create { + path: "notes/owned-publication.txt".into(), + content_type: server_api::ConfigContentType::Text, + content: "committed before prompt publication".into(), + }], + }), + ), + )); + assert!(futures::poll!(&mut commit).is_pending()); + let request = observed(reply.entered.take().unwrap()).await.unwrap(); + assert!( + request.starts_with("POST "), + "prompt publication: {request}" + ); + let config_store = fixture.api.config_store.clone(); + let state = observed( + fixture + .executor + .run(move || config_store.load_workspace_config(WORKSPACE)), + ) + .await + .unwrap() + .unwrap(); + assert_ne!( + state.snapshot.digest, current.snapshot.digest, + "publication must observe the committed tree" + ); + assert!( + state + .snapshot + .entries + .keys() + .any(|path| path.to_string() == "notes/owned-publication.txt") + ); + observed(tokio::time::sleep(StdDuration::from_millis(1))).await; + assert_eq!( + observed(tokio::spawn(async { "control" })).await.unwrap(), + "control" + ); + assert!(!reply.upstream_released.load(Ordering::SeqCst)); + drop(commit); + assert_eq!(leases.load(Ordering::SeqCst), 1); + reply.finish(); + observed(fixture.host_executor.drain()).await.unwrap(); + observed(fixture.executor.drain()).await.unwrap(); + assert_eq!(leases.load(Ordering::SeqCst), 0); + assert_eq!( + fixture + .api + .config_store + .load_workspace_config(WORKSPACE) + .unwrap() + .unwrap() + .snapshot + .digest, + state.snapshot.digest, + "failed best-effort publication must not repeat or roll back the commit" + ); +} + +#[tokio::test(flavor = "current_thread")] +async fn repository_provider_reads_do_not_consume_the_sql_execution_queue() { + let fixture = fixture().await; + let (entered, waiting) = oneshot::channel(); + let (release, permit) = mpsc::channel(); + fixture + .executor + .dispatch(move || { + let _ = entered.send(()); + let _ = permit.recv_timeout(WATCHDOG); + }) + .unwrap(); + observed(waiting).await.unwrap(); + fixture.executor.dispatch(|| {}).unwrap(); + let probe = fixture.executor.run(|| Ok::<_, Error>(())).await; + assert!(matches!(probe, Err(Error::DbCapacityExceeded))); + // Empty catalog / absent key are deliberate boundary inputs: provider + // reads keep their ordinary response even when SQL job admission is full. + let list = observed(list_repositories(State(fixture.api.clone()))) + .await + .unwrap(); + assert!(list.0.items.is_empty()); + let detail = observed(repository_detail( + State(fixture.api.clone()), + AxumPath("missing".into()), + )) + .await + .unwrap_err(); + response_json(detail.into_response(), StatusCode::NOT_FOUND).await; + let log = observed(repository_log( + State(fixture.api.clone()), + AxumPath("missing".into()), + Query(LogQuery { limit: Some(1) }), + )) + .await + .unwrap_err(); + response_json(log.into_response(), StatusCode::NOT_FOUND).await; + let _ = release.send(()); + observed(fixture.executor.drain()).await.unwrap(); + observed(fixture.host_executor.drain()).await.unwrap(); +} diff --git a/crates/workspace-server/src/server_ticket_case_signed_tests.rs b/crates/workspace-server/src/server_ticket_case_signed_tests.rs new file mode 100644 index 000000000..8f7c38b52 --- /dev/null +++ b/crates/workspace-server/src/server_ticket_case_signed_tests.rs @@ -0,0 +1,758 @@ +use super::*; + +async fn case_response( + app: Router, + request: Request, + expected: StatusCode, +) -> serde_json::Value { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body)); + if body.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&body).unwrap() + } +} + +fn signed_case_request( + identity: &RuntimeIdentityMaterial, + worker: Option<&str>, + ticket: &str, + input: &ticket::TicketCaseUpdate, +) -> Request { + let mut body = serde_json::to_value(input).unwrap(); + body.as_object_mut().unwrap().remove("author"); + runtime_source_request( + identity, + worker, + "POST", + &format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/case"), + serde_json::to_vec(&body).unwrap(), + ) +} + +#[tokio::test] +async fn signed_unassigned_worker_without_hints_cannot_close_reopen_or_correct_cases() { + let dir = tempfile::tempdir().unwrap(); + let (mut api, execution) = test_api_with_recording_backend(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + seed_worker_source_member(&api, "runtime-case", "other"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Case authority")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "owner"), + "unauthorized-owner", + ); + let app = build_router(api.clone()); + for action in [ + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ticket::TicketCaseAction::Reopen, + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ] { + if !matches!(action, ticket::TicketCaseAction::Close { .. }) { + let before = backend.show(reference.id.clone().into()).unwrap(); + if before.meta.workflow_state == TicketState::Open { + backend + .update_case( + &reference.id, + case_test_request( + &before, + "fixture-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ), + ) + .unwrap(); + } + } + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request(&before, "unauthorized", action); + let authority = case_authority_snapshot(&api); + case_response( + app.clone(), + signed_case_request(&identity, Some("other"), &reference.id, &input), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + assert_eq!(case_authority_snapshot(&api), authority); + } + assert!(execution.protocol_methods().is_empty()); +} + +#[tokio::test] +async fn signed_runtime_without_worker_cannot_make_case_judgments() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Runtime is not a case actor")) + .unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request( + &before, + "runtime-only", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + case_response( + build_router(api.clone()), + signed_case_request(&identity, None, &reference.id, &input), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), before); +} + +fn seed_case_assignment(api: &WorkspaceApi, ticket: &str, worker: &RuntimeWorkerRef, key: &str) { + seed_worker_source_member(api, &worker.runtime_id, &worker.worker_id); + api.store + .set_current_ticket_worker_assignment( + &TicketWorkerAssignmentRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + ticket_id: ticket.into(), + assignment_id: key.into(), + worker: worker.clone(), + assigned_by: "test".into(), + assigned_at: TEST_CREATED_AT.into(), + }, + None, + &format!("event-{key}"), + &format!("operation-{key}"), + false, + ) + .unwrap(); +} + +fn case_authority_snapshot(api: &WorkspaceApi) -> Vec>> { + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + [ + "worker_registry", + "worker_control_grants", + "ticket_current_worker_assignments", + "ticket_assignment_operations", + "ticket_assignment_work_releases", + "backend_jobs", + ] + .into_iter() + .map(|table| { + let mut statement = conn + .prepare(&format!( + "SELECT * FROM {table} WHERE workspace_id=?1 ORDER BY rowid" + )) + .unwrap(); + let columns = statement.column_count(); + statement + .query_map([TEST_WORKSPACE_ID], |row| { + (0..columns) + .map(|column| row.get(column)) + .collect::>>() + }) + .unwrap() + .collect::>>() + .unwrap() + }) + .collect() +} + +#[tokio::test] +async fn signed_assigned_worker_case_replay_keeps_original_actor_after_release_without_restoring_grant() + { + let dir = tempfile::tempdir().unwrap(); + let (mut api, execution) = test_api_with_recording_backend(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let source = RuntimeWorkerRef::new("runtime-case", "owner"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Source-bound case receipt")) + .unwrap(); + seed_case_assignment(&api, &reference.id, &source, "original-work"); + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request( + &before, + "signed-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let app = build_router(api.clone()); + let first = signed_case_request(&identity, Some("owner"), &reference.id, &input); + let old_proof = + first.headers()[worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER].clone(); + let response = case_response(app.clone(), first, StatusCode::OK).await; + assert_eq!(response["workflow_state"], "closed"); + let closed = backend.show(reference.id.clone().into()).unwrap(); + let event = closed.events.last().unwrap(); + assert_eq!( + closed.case_history.last().unwrap().author, + "worker:runtime-case/owner" + ); + assert_eq!(event.author.as_deref(), Some("worker:runtime-case/owner")); + for (key, expected) in [ + ("source_runtime_id", "runtime-case"), + ("source_worker_id", "owner"), + ("source_actor_role", "ticket_worker"), + ("source_assignment_id", "original-work"), + ] { + assert_eq!( + event.attributes.get(key).map(String::as_str), + Some(expected) + ); + } + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &reference.id) + .unwrap() + .is_none() + ); + assert_eq!( + api.store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &reference.id) + .unwrap() + .unwrap() + .worker, + source + ); + let released = case_authority_snapshot(&api); + // Proof tokens are single-use; operation receipts survive fresh signatures. + let mut reused_proof = signed_case_request(&identity, Some("owner"), &reference.id, &input); + reused_proof.headers_mut().insert( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + old_proof, + ); + case_response(app.clone(), reused_proof, StatusCode::UNAUTHORIZED).await; + assert_eq!( + case_response( + app.clone(), + signed_case_request(&identity, Some("owner"), &reference.id, &input), + StatusCode::OK + ) + .await, + response + ); + let mut changed = input.clone(); + changed.explanation = "Changed judgment under the same key".into(); + case_response( + app.clone(), + signed_case_request(&identity, Some("owner"), &reference.id, &changed), + StatusCode::CONFLICT, + ) + .await; + let reopen = case_test_request( + &closed, + "released-worker-reopen", + ticket::TicketCaseAction::Reopen, + ); + case_response( + app.clone(), + signed_case_request(&identity, Some("owner"), &reference.id, &reopen), + StatusCode::FORBIDDEN, + ) + .await; + let correction = case_test_request( + &closed, + "released-worker-correction", + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + case_response( + app, + signed_case_request(&identity, Some("owner"), &reference.id, &correction), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), closed); + assert_eq!(case_authority_snapshot(&api), released); + assert!(execution.protocol_methods().is_empty()); +} + +#[tokio::test] +async fn signed_case_hints_cannot_replace_verified_worker_or_human_identity() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Hints are assertions")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "owner"), + "hint-owner", + ); + seed_worker_source_member(&api, "runtime-case", "other"); + let before = backend.show(reference.id.clone().into()).unwrap(); + let authority = case_authority_snapshot(&api); + let input = case_test_request( + &before, + "hint-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let app = build_router(api.clone()); + for (worker, runtime_hint, worker_hint) in [ + (Some("other"), Some("runtime-case"), Some("owner")), + (Some("owner"), Some("another-runtime"), Some("owner")), + (Some("owner"), Some("runtime-case"), None), + (None, Some("runtime-case"), Some("owner")), + ] { + let mut request = signed_case_request(&identity, worker, &reference.id, &input); + if let Some(runtime) = runtime_hint { + request + .headers_mut() + .insert("x-yoi-runtime-id", HeaderValue::from_str(runtime).unwrap()); + } + if let Some(worker) = worker_hint { + request + .headers_mut() + .insert("x-yoi-worker-id", HeaderValue::from_str(worker).unwrap()); + } + case_response(app.clone(), request, StatusCode::FORBIDDEN).await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + assert_eq!(case_authority_snapshot(&api), authority); + } + let token = seed_test_api_token(api.store.as_ref(), "hint-human"); + let mut human = signed_case_request(&identity, Some("owner"), &reference.id, &input); + human + .headers_mut() + .remove(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER); + human.headers_mut().insert( + "authorization", + HeaderValue::from_str(&format!("Bearer {token}")).unwrap(), + ); + human + .headers_mut() + .insert("x-yoi-runtime-id", HeaderValue::from_static("runtime-case")); + human + .headers_mut() + .insert("x-yoi-worker-id", HeaderValue::from_static("owner")); + case_response(app.clone(), human, StatusCode::FORBIDDEN).await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + assert_eq!(case_authority_snapshot(&api), authority); + let mut matching = signed_case_request(&identity, Some("owner"), &reference.id, &input); + matching + .headers_mut() + .insert("x-yoi-runtime-id", HeaderValue::from_static("runtime-case")); + matching + .headers_mut() + .insert("x-yoi-worker-id", HeaderValue::from_static("owner")); + case_response(app, matching, StatusCode::OK).await; +} + +#[tokio::test] +async fn case_receipt_cannot_be_replayed_by_different_authenticated_worker() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Distinct Worker receipt identity")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "first"), + "first-work", + ); + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request( + &before, + "identity-key", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let app = build_router(api.clone()); + case_response( + app.clone(), + signed_case_request(&identity, Some("first"), &reference.id, &input), + StatusCode::OK, + ) + .await; + let closed = backend.show(reference.id.clone().into()).unwrap(); + backend + .update_case( + &reference.id, + case_test_request(&closed, "fixture-reopen", ticket::TicketCaseAction::Reopen), + ) + .unwrap(); + api.store + .clear_current_ticket_worker_assignment( + TEST_WORKSPACE_ID, + &reference.id, + Some("first-work"), + "replace-operation", + "replace-event", + "test", + TEST_CREATED_AT, + ) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "second"), + "second-work", + ); + let reopened = backend.show(reference.id.clone().into()).unwrap(); + let authority = case_authority_snapshot(&api); + case_response( + app, + signed_case_request(&identity, Some("second"), &reference.id, &input), + StatusCode::CONFLICT, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), reopened); + assert_eq!(case_authority_snapshot(&api), authority); +} + +// The generic dispatcher has no public UpdateCase route of its own. Compose its +// module boundary with the same real middleware, rather than inventing claims. +fn generic_case_router(api: WorkspaceApi) -> Router { + async fn update( + State(api): State, + axum::Extension(context): axum::Extension, + AxumPath((workspace, ticket)): AxumPath<(String, String)>, + headers: HeaderMap, + Json(request): Json, + ) -> ApiResult> { + let result = execute_ticket_rest_operation_with_context( + &api, + &workspace, + headers, + Some(&context), + TicketBackendOperation::UpdateCase { ticket, request }, + ) + .await?; + match result { + TicketBackendOperationResult::Ticket(ticket) => Ok(Json(ticket)), + _ => panic!("case dispatch returned a non-Ticket result"), + } + } + Router::new() + .route( + "/workspaces/{workspace_id}/tickets/{ticket_id}/case", + post(update), + ) + .with_state(api.clone()) + .layer(middleware::from_fn_with_state( + ServerApiContractService::Workspace(api.clone()), + attach_server_request_context, + )) + .layer(middleware::from_fn_with_state( + api, + authorize_workspace_api_request, + )) +} + +#[tokio::test] +async fn typed_and_generic_case_dispatch_keep_the_same_verified_worker_and_receipt() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let source = RuntimeWorkerRef::new("runtime-case", "owner"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Dispatch parity")) + .unwrap(); + seed_case_assignment(&api, &reference.id, &source, "dispatch-work"); + seed_worker_source_member(&api, "runtime-case", "other"); + let before = backend.show(reference.id.clone().into()).unwrap(); + let mut input = case_test_request( + &before, + "generic-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + input.author = Some("forged-client-actor".into()); + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let generic = generic_case_router(api.clone()); + for worker in [Some("other"), None] { + case_response( + generic.clone(), + signed_case_request(&identity, worker, &reference.id, &input), + StatusCode::FORBIDDEN, + ) + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + } + let closed = case_response( + generic.clone(), + runtime_source_request( + &identity, + Some("owner"), + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ), + StatusCode::OK, + ) + .await; + assert_eq!( + closed["case_history"][0]["author"], + "worker:runtime-case/owner" + ); + case_response( + build_router(api.clone()), + signed_case_request(&identity, Some("owner"), &reference.id, &input), + StatusCode::OK, + ) + .await; + assert_eq!( + case_response( + generic, + runtime_source_request( + &identity, + Some("owner"), + "POST", + &path, + serde_json::to_vec(&input).unwrap() + ), + StatusCode::OK + ) + .await, + closed + ); + let error = execute_ticket_rest_operation_with_context( + &api, + TEST_WORKSPACE_ID, + HeaderMap::new(), + None, + TicketBackendOperation::UpdateCase { + ticket: reference.id.clone(), + request: input, + }, + ) + .await + .unwrap_err(); + assert_eq!(error.into_response().status(), StatusCode::FORBIDDEN); + assert_eq!( + serde_json::to_value(backend.show(reference.id.into()).unwrap()).unwrap(), + closed + ); +} + +#[tokio::test] +async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account_and_user() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let token = seed_test_api_token(api.store.as_ref(), "case-human"); + let other_token = seed_test_api_token(api.store.as_ref(), "other-human"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Human case identity")) + .unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + let mut input = case_test_request( + &before, + "human-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + input.author = Some("forged-author".into()); + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let human_request = |token: &str, typed: bool| { + let mut body = serde_json::to_value(&input).unwrap(); + if typed { + body.as_object_mut().unwrap().remove("author"); + } + Request::builder() + .method("POST") + .uri(&path) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap() + }; + let typed = build_router(api.clone()); + let response = case_response(typed.clone(), human_request(&token, true), StatusCode::OK).await; + let closed = backend.show(reference.id.clone().into()).unwrap(); + let event = closed.events.last().unwrap(); + assert_eq!(event.attributes["source_account_id"], "account-case-human"); + assert_eq!(event.attributes["source_user_id"], "user-case-human"); + assert_eq!( + event.author.as_deref(), + Some("workspace-user:[\"account-case-human\",\"user-case-human\"]") + ); + assert_eq!( + closed.case_history.last().unwrap().author, + event.author.clone().unwrap() + ); + assert_eq!( + case_response(typed.clone(), human_request(&token, true), StatusCode::OK).await, + response + ); + case_response( + typed, + human_request(&other_token, true), + StatusCode::CONFLICT, + ) + .await; + // Generic dispatch receives an explicit untrusted author but must override it. + case_response( + generic_case_router(api.clone()), + human_request(&token, false), + StatusCode::OK, + ) + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), closed); + // A human reopen preserves the previous immutable judgment and its actor. + let reopen = case_test_request(&closed, "human-reopen", ticket::TicketCaseAction::Reopen); + let mut body = serde_json::to_value(reopen).unwrap(); + body.as_object_mut().unwrap().remove("author"); + case_response( + build_router(api.clone()), + Request::builder() + .method("POST") + .uri(path) + .header("authorization", format!("Bearer {token}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(serde_json::to_vec(&body).unwrap())) + .unwrap(), + StatusCode::OK, + ) + .await; + let reopened = backend.show(reference.id.into()).unwrap(); + assert_eq!(reopened.meta.workflow_state, TicketState::Open); + assert_eq!(reopened.case_history[0], closed.case_history[0]); + assert_eq!( + reopened.events.last().unwrap().attributes["source_user_id"], + "user-case-human" + ); +} + +#[tokio::test] +async fn public_case_route_requires_valid_authentication_and_workspace_scope_before_writes() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Authenticated route")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "owner"), + "auth-work", + ); + let before = backend.show(reference.id.clone().into()).unwrap(); + let authority = case_authority_snapshot(&api); + let input = case_test_request( + &before, + "invalid-proof", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let app = build_router(api.clone()); + let mut unsigned = signed_case_request(&identity, Some("owner"), &reference.id, &input); + unsigned + .headers_mut() + .remove(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER); + case_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await; + let attacker = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + case_response( + app.clone(), + signed_case_request(&attacker, Some("owner"), &reference.id, &input), + StatusCode::UNAUTHORIZED, + ) + .await; + let mut tampered = signed_case_request(&identity, Some("owner"), &reference.id, &input); + *tampered.body_mut() = Body::from("{}"); + case_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await; + let mut wrong_scope = signed_case_request(&identity, Some("owner"), &reference.id, &input); + *wrong_scope.uri_mut() = format!("/workspaces/other-workspace/tickets/{}/case", reference.id) + .parse() + .unwrap(); + case_response(app, wrong_scope, StatusCode::UNAUTHORIZED).await; + assert_eq!(backend.show(reference.id.into()).unwrap(), before); + assert_eq!(case_authority_snapshot(&api), authority); +} + +#[tokio::test] +async fn catalog_case_dispatch_authenticates_and_routes_the_canonical_workspace_path() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-case").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-case"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Catalog routing boundary")) + .unwrap(); + seed_case_assignment( + &api, + &reference.id, + &RuntimeWorkerRef::new("runtime-case", "owner"), + "catalog-work", + ); + let before = backend.show(reference.id.clone().into()).unwrap(); + let input = case_test_request( + &before, + "catalog-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + let catalog = WorkspaceServerApi::new_for_test(api.config.clone(), api.store.clone()); + // Retain only this in-process fixture: do not create a real Runtime or a + // background Orchestrator hook while testing catalog authentication/routing. + catalog + .apis + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), api.clone()); + catalog + .routers + .lock() + .await + .insert(TEST_WORKSPACE_ID.into(), build_inner_router(api.clone())); + let app = workspace_server_router(catalog); + let mut unsigned = signed_case_request(&identity, Some("owner"), &reference.id, &input); + unsigned + .headers_mut() + .remove(worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER); + case_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + let response = case_response( + app, + signed_case_request(&identity, Some("owner"), &reference.id, &input), + StatusCode::OK, + ) + .await; + assert_eq!(response["workflow_state"], "closed"); + assert_eq!( + backend.show(reference.id.into()).unwrap().case_history[0].author, + "worker:runtime-case/owner" + ); +} diff --git a/crates/workspace-server/src/server_ticket_tags_tests.rs b/crates/workspace-server/src/server_ticket_tags_tests.rs new file mode 100644 index 000000000..1608982bd --- /dev/null +++ b/crates/workspace-server/src/server_ticket_tags_tests.rs @@ -0,0 +1,971 @@ +use super::*; + +fn tag_test_case_update( + backend: &ticket::SqliteTicketBackend, + id: &str, + key: &str, + action: ticket::TicketCaseAction, +) -> ticket::Ticket { + let current = backend.show(id.into()).unwrap(); + backend + .update_case( + id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(¤t), + expected_state: current.meta.workflow_state, + expected_closure_operation_key: current.closure_operation_key, + operation_key: key.into(), + action, + explanation: "Independent case judgment".into(), + references: vec![], + author: Some("case-fixture".into()), + }, + ) + .unwrap() +} + +// Exercise the generated tag routes behind real Runtime authentication. Each +// operation retry gets a fresh proof; an HTTP proof itself is single-use. +async fn runtime_tag_response( + app: Router, + request: Request, + expected: StatusCode, +) -> serde_json::Value { + let response = app.oneshot(request).await.unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!(status, expected, "{}", String::from_utf8_lossy(&body)); + if body.is_empty() { + serde_json::Value::Null + } else { + serde_json::from_slice(&body).unwrap_or_else(|error| { + panic!( + "invalid JSON response: {error}: {}", + String::from_utf8_lossy(&body) + ) + }) + } +} + +async fn runtime_tag_json( + app: Router, + identity: &RuntimeIdentityMaterial, + worker_id: &str, + method: &str, + path: &str, + input: serde_json::Value, + expected: StatusCode, +) -> serde_json::Value { + runtime_tag_response( + app, + runtime_source_request( + identity, + Some(worker_id), + method, + path, + serde_json::to_vec(&input).unwrap(), + ), + expected, + ) + .await +} + +fn tag_execution_authority_snapshot(api: &WorkspaceApi) -> Vec>> { + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + [ + "worker_registry", + "worker_control_grants", + "ticket_current_worker_assignments", + "ticket_assignment_operations", + "backend_jobs", + ] + .into_iter() + .map(|table| { + let mut statement = conn + .prepare(&format!( + "SELECT * FROM {table} WHERE workspace_id=?1 ORDER BY rowid" + )) + .unwrap(); + let columns = statement.column_count(); + statement + .query_map([TEST_WORKSPACE_ID], |row| { + (0..columns) + .map(|column| row.get(column)) + .collect::>>() + }) + .unwrap() + .collect::>>() + .unwrap() + }) + .collect() +} + +#[tokio::test] +async fn runtime_tag_mutations_replay_original_receipts_and_preserve_execution_authority() { + let dir = tempfile::tempdir().unwrap(); + let (mut api, execution) = test_api_with_recording_backend(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-tags"); + let source = RuntimeWorkerRef::new("runtime-tags", "classifier"); + let recipient = RuntimeWorkerRef::new("runtime-tags", "recipient"); + seed_worker_source_member(&api, &source.runtime_id, &source.worker_id); + seed_worker_source_member(&api, &recipient.runtime_id, &recipient.worker_id); + seed_worker_control_grant(&api, &source, &recipient, "existing-control"); + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Classification is not a decision")) + .unwrap(); + // Routing is an immutable receipt on an open case, not a six-state value. + let Json(queue) = scoped_queue_ticket_record( + State(api.clone()), + AxumPath((TEST_WORKSPACE_ID.into(), reference.id.clone())), + HeaderMap::new(), + ) + .await + .unwrap(); + assert_eq!(queue.queued_tickets, vec![reference.id.clone()]); + assert_eq!( + backend + .show(reference.id.clone().into()) + .unwrap() + .meta + .workflow_state, + ticket::TicketState::Open + ); + api.store + .set_current_ticket_role_assignment( + &TicketRoleAssignmentRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + ticket_id: reference.id.clone(), + assignment_id: "existing-assignment".into(), + role: TicketAssignmentRole::Worker, + principal: TicketAssignmentPrincipal::Worker { + runtime_id: recipient.runtime_id.clone(), + worker_id: recipient.worker_id.clone(), + }, + assigned_by: "test".into(), + assigned_at: TEST_CREATED_AT.into(), + }, + None, + "existing-assignment-event", + "existing-assignment-operation", + false, + ) + .unwrap(); + let authority_before = tag_execution_authority_snapshot(&api); + let ticket_before = backend.show(reference.id.clone().into()).unwrap(); + let app = build_router(api.clone()); + let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", reference.id); + let create = + json!({"operation_key":"runtime-create", "action":{"kind":"create", "name":" ready "}}); + let tag = runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "POST", + &tags_path, + create.clone(), + StatusCode::OK, + ) + .await; + assert_eq!(tag["name"], "ready"); + assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID); + let attach = + json!({"operation_key":"runtime-attach", "expected_tag_ids":[], "tag_ids":[tag["id"]]}); + let attached = runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "PUT", + &membership_path, + attach.clone(), + StatusCode::OK, + ) + .await; + assert_eq!(attached, json!([tag.clone()])); + assert_eq!( + runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "PUT", + &membership_path, + attach.clone(), + StatusCode::OK + ) + .await, + attached + ); + let rename = json!({"operation_key":"runtime-rename", "action":{"kind":"rename", "tag_id":tag["id"], "expected_last_operation_key":"runtime-create", "name":"Ready now"}}); + let renamed = runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "POST", + &tags_path, + rename.clone(), + StatusCode::OK, + ) + .await; + assert_eq!(renamed["id"], tag["id"]); + assert_eq!(renamed["name"], "Ready now"); + assert_eq!( + serde_json::to_value(&backend.show(reference.id.clone().into()).unwrap().tags).unwrap(), + json!([renamed.clone()]) + ); + let delete = json!({"operation_key":"runtime-delete", "action":{"kind":"delete", "tag_id":tag["id"], "expected_last_operation_key":"runtime-rename"}}); + let deleted = runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "POST", + &tags_path, + delete.clone(), + StatusCode::OK, + ) + .await; + assert_eq!(deleted["deleted"], true); + assert!( + backend + .show(reference.id.clone().into()) + .unwrap() + .tags + .is_empty() + ); + // Receipts return their original snapshots, even after rename/delete changed + // live state. Retries must not recreate membership or duplicate audit events. + for (input, expected) in [(create, tag), (rename, renamed), (delete, deleted)] { + assert_eq!( + runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "POST", + &tags_path, + input, + StatusCode::OK + ) + .await, + expected + ); + } + assert_eq!( + runtime_tag_json( + app.clone(), + &identity, + &source.worker_id, + "PUT", + &membership_path, + attach, + StatusCode::OK + ) + .await, + attached + ); + runtime_tag_json(app.clone(), &identity, &source.worker_id, "PUT", &membership_path, + json!({"operation_key":"runtime-stale-set", "expected_tag_ids":[attached[0]["id"]], "tag_ids":[]}), StatusCode::CONFLICT).await; + let empty = runtime_tag_json( + app, + &identity, + &source.worker_id, + "PUT", + &membership_path, + json!({"operation_key":"runtime-empty-set", "expected_tag_ids":[], "tag_ids":[]}), + StatusCode::OK, + ) + .await; + assert_eq!(empty, json!([])); + let ticket_after = backend.show(reference.id.into()).unwrap(); + assert_eq!( + ticket::ticket_content_digest(&ticket_before), + ticket::ticket_content_digest(&ticket_after) + ); + assert_eq!( + ticket_before.meta.workflow_state, + ticket_after.meta.workflow_state + ); + assert_eq!(ticket_before.current_closure, ticket_after.current_closure); + assert_eq!(ticket_before.case_history, ticket_after.case_history); + assert_eq!(ticket_before.meta.queued_by, ticket_after.meta.queued_by); + assert_eq!(ticket_before.meta.queued_at, ticket_after.meta.queued_at); + assert_eq!( + ticket_before + .events + .iter() + .filter(|event| event.kind != ticket::TicketEventKind::TagsChanged) + .collect::>(), + ticket_after + .events + .iter() + .filter(|event| event.kind != ticket::TicketEventKind::TagsChanged) + .collect::>() + ); + assert!(ticket_after.tags.is_empty()); + assert_eq!(tag_execution_authority_snapshot(&api), authority_before); + assert!( + execution.protocol_methods().is_empty(), + "classification dispatched execution/routing" + ); + let tag_events: Vec<_> = ticket_after + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .collect(); + assert_eq!( + tag_events.len(), + 2, + "attach/delete retries duplicated membership audit" + ); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + let mut statement = conn.prepare("SELECT operation_key,source_context_json FROM workspace_tag_operations WHERE workspace_id=?1 ORDER BY operation_key").unwrap(); + let receipts = statement + .query_map([TEST_WORKSPACE_ID], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + }) + .unwrap() + .collect::>>() + .unwrap(); + assert_eq!(receipts.len(), 5); + for (key, context) in receipts { + let context: serde_json::Value = serde_json::from_str(&context).unwrap(); + assert_eq!( + context["source_runtime_id"], source.runtime_id, + "receipt {key}: {context}" + ); + assert_eq!( + context["source_worker_id"], source.worker_id, + "receipt {key}: {context}" + ); + assert!( + context.get("source_account_id").is_none(), + "signed source became a human actor: {context}" + ); + } + for event in tag_events { + assert_eq!( + event.attributes.get("source_runtime_id"), + Some(&source.runtime_id) + ); + assert_eq!( + event.attributes.get("source_worker_id"), + Some(&source.worker_id) + ); + } +} + +#[tokio::test] +async fn runtime_tag_receipt_replay_is_bound_to_verified_worker_not_transport_proof() { + let dir = tempfile::tempdir().unwrap(); + let mut api = test_api(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-tags"); + for worker in ["classifier", "other"] { + seed_worker_source_member(&api, "runtime-tags", worker); + } + let app = build_router(api.clone()); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let input = + json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"ready"}}); + let request = runtime_source_request( + &identity, + Some("classifier"), + "POST", + &path, + serde_json::to_vec(&input).unwrap(), + ); + let proof = + request.headers()[worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER].clone(); + let tag = runtime_tag_response(app.clone(), request, StatusCode::OK).await; + runtime_tag_response( + app.clone(), + Request::builder() + .method("POST") + .uri(&path) + .header(CONTENT_TYPE, "application/json") + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .body(Body::from(serde_json::to_vec(&input).unwrap())) + .unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + assert_eq!( + runtime_tag_json( + app.clone(), + &identity, + "classifier", + "POST", + &path, + input.clone(), + StatusCode::OK + ) + .await, + tag + ); + runtime_tag_json( + app.clone(), + &identity, + "classifier", + "POST", + &path, + json!({"operation_key":"worker-receipt", "action":{"kind":"create", "name":"changed"}}), + StatusCode::CONFLICT, + ) + .await; + runtime_tag_json( + app, + &identity, + "other", + "POST", + &path, + input, + StatusCode::CONFLICT, + ) + .await; + assert_eq!( + browser_ticket_backend(&api) + .unwrap() + .list_workspace_tags(ticket::TagListRequest::default()) + .unwrap() + .len(), + 1 + ); +} + +#[tokio::test] +async fn runtime_tag_mutations_reject_invalid_proofs_and_changed_binding_authority() { + let dir = tempfile::tempdir().unwrap(); + let (mut api, execution) = test_api_with_recording_backend(dir.path()).await; + let identity = RuntimeIdentityMaterial::generate("runtime-tags").unwrap(); + configure_runtime_request_auth(&mut api, &identity, "runtime-tags"); + seed_worker_source_member(&api, "runtime-tags", "classifier"); + let backend = browser_ticket_backend(&api).unwrap(); + let ticket = backend + .create(ticket::NewTicket::new("Rejected classification")) + .unwrap(); + let ticket_before = backend.show(ticket.id.clone().into()).unwrap(); + let authority_before = tag_execution_authority_snapshot(&api); + let app = build_router(api.clone()); + let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let membership_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/tags", ticket.id); + let attacker = RuntimeIdentityMaterial::generate("runtime-tags").unwrap(); + // Both mutation routes must reject before writing tags, memberships, receipts + // or audit events. There is no persisted verification status in this fixture. + for (method, path, input) in [ + ( + "POST", + tags_path.as_str(), + json!({"operation_key":"denied-create", "action":{"kind":"create", "name":"denied"}}), + ), + ( + "PUT", + membership_path.as_str(), + json!({"operation_key":"denied-set", "expected_tag_ids":[], "tag_ids":[]}), + ), + ] { + let body = serde_json::to_vec(&input).unwrap(); + let unsigned = Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header("x-yoi-runtime-id", "runtime-tags") + .header("x-yoi-worker-id", "classifier") + .body(Body::from(body.clone())) + .unwrap(); + runtime_tag_response(app.clone(), unsigned, StatusCode::UNAUTHORIZED).await; + runtime_tag_json( + app.clone(), + &attacker, + "classifier", + method, + path, + input.clone(), + StatusCode::UNAUTHORIZED, + ) + .await; + runtime_tag_json( + app.clone(), + &identity, + "not-a-member", + method, + path, + input.clone(), + StatusCode::UNAUTHORIZED, + ) + .await; + let proof = worker_runtime::auth::RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-test", + "another-workspace", + Some("classifier"), + worker_runtime::auth::WORKSPACE_REQUEST_PERMISSION, + method, + path, + &body, + i64::try_from(worker_runtime::auth::unix_now_seconds()).unwrap(), + 30, + ) + .unwrap(); + runtime_tag_response( + app.clone(), + Request::builder() + .method(method) + .uri(path) + .header(CONTENT_TYPE, "application/json") + .header( + worker_runtime::auth::RUNTIME_REQUEST_SOURCE_PROOF_HEADER, + proof, + ) + .body(Body::from(body.clone())) + .unwrap(), + StatusCode::UNAUTHORIZED, + ) + .await; + let mut tampered = + runtime_source_request(&identity, Some("classifier"), method, path, body); + *tampered.body_mut() = Body::from(format!("{} ", input)); + runtime_tag_response(app.clone(), tampered, StatusCode::UNAUTHORIZED).await; + } + let store = SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap(); + let original = api + .store + .get_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags") + .await + .unwrap() + .unwrap(); + let mut replacement = original.clone(); + replacement.public_key = attacker.public_key.clone(); + replacement.public_key_fingerprint = String::new(); + store + .upsert_workspace_runtime_binding(replacement, true) + .unwrap(); + for (method, path, input) in [ + ( + "POST", + tags_path.as_str(), + json!({"operation_key":"stale-key-create", "action":{"kind":"create", "name":"stale"}}), + ), + ( + "PUT", + membership_path.as_str(), + json!({"operation_key":"stale-key-set", "expected_tag_ids":[], "tag_ids":[]}), + ), + ] { + runtime_tag_json( + app.clone(), + &identity, + "classifier", + method, + path, + input, + StatusCode::UNAUTHORIZED, + ) + .await; + } + store + .upsert_workspace_runtime_binding(original, true) + .unwrap(); + store + .revoke_workspace_runtime_binding(TEST_WORKSPACE_ID, "runtime-tags", TEST_CREATED_AT) + .unwrap(); + for (method, path, input) in [ + ( + "POST", + tags_path.as_str(), + json!({"operation_key":"revoked-create", "action":{"kind":"create", "name":"revoked"}}), + ), + ( + "PUT", + membership_path.as_str(), + json!({"operation_key":"revoked-set", "expected_tag_ids":[], "tag_ids":[]}), + ), + ] { + runtime_tag_json( + app.clone(), + &identity, + "classifier", + method, + path, + input, + StatusCode::UNAUTHORIZED, + ) + .await; + } + assert!( + backend + .list_workspace_tags(ticket::TagListRequest::default()) + .unwrap() + .is_empty() + ); + assert_eq!( + serde_json::to_value(backend.show(ticket.id.into()).unwrap()).unwrap(), + serde_json::to_value(ticket_before).unwrap() + ); + assert_eq!(tag_execution_authority_snapshot(&api), authority_before); + assert!(execution.protocol_methods().is_empty()); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + let receipts: i64 = conn + .query_row( + "SELECT COUNT(*) FROM workspace_tag_operations WHERE workspace_id=?1", + [TEST_WORKSPACE_ID], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(receipts, 0); +} + +#[tokio::test] +async fn ticket_tags_round_trip_filter_and_preserve_execution_and_decision_projections() { + let dir = tempfile::tempdir().unwrap(); + let (api, execution) = test_api_with_recording_backend(dir.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("Classification only")) + .unwrap(); + let other = backend + .create(ticket::NewTicket::new("Other ticket")) + .unwrap(); + let reasoned = tag_test_case_update( + &backend, + &reference.id, + "reasoned-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + let app = build_inner_router(api.clone()); + let tags_path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let ticket_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id); + let before = get_json(app.clone(), &ticket_path).await; + let ready = post_json( + app.clone(), + &tags_path, + json!({ + "operation_key":"create-ready", "action":{"kind":"create", "name":" ready "} + }), + ) + .await; + let bug = post_json( + app.clone(), + &tags_path, + json!({ + "operation_key":"create-bug", "action":{"kind":"create", "name":"closed"} + }), + ) + .await; + let ready_id = ready["id"].as_str().unwrap(); + let bug_id = bug["id"].as_str().unwrap(); + let assignment = + json!({"operation_key":"classify", "expected_tag_ids":[], "tag_ids":[ready_id,bug_id]}); + let attached = request_json( + app.clone(), + "PUT", + &format!("{ticket_path}/tags"), + Some(assignment.clone()), + StatusCode::OK, + ) + .await; + assert_eq!(attached.as_array().unwrap().len(), 2); + let replay = request_json( + app.clone(), + "PUT", + &format!("{ticket_path}/tags"), + Some(assignment), + StatusCode::OK, + ) + .await; + assert_eq!(attached, replay); + let after = get_json(app.clone(), &ticket_path).await; + for field in [ + "state", + "current_closure", + "closure_operation_key", + "legacy_handoff", + "content_digest", + "current_worker", + "assignments", + "action_eligibility", + "targets", + "evidence", + "merge_requests", + "queued_by", + "queued_at", + ] { + assert_eq!( + before[field], after[field], + "classification changed {field}" + ); + } + assert_eq!(after["tags"].as_array().unwrap().len(), 2); + assert!(execution.protocol_methods().is_empty()); + let list = get_json(app.clone(), &format!("/api/w/{TEST_WORKSPACE_ID}/tickets")).await; + let item = list["items"] + .as_array() + .unwrap() + .iter() + .find(|item| item["id"] == reference.id) + .unwrap(); + assert_eq!(item["tags"].as_array().unwrap().len(), 2); + let query_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"); + for (filter, count) in [ + (json!({"tag_ids":[ready_id,bug_id]}), 1), + (json!({"tag_ids_any":[ready_id,"absent"]}), 1), + (json!({"tag_ids_none":[ready_id]}), 1), + (json!({"tag_ids":[ready_id,"absent"]}), 0), + ] { + let result = post_json(app.clone(), &query_path, filter.clone()).await; + assert_eq!( + result["items"].as_array().unwrap().len(), + count, + "filter {filter}" + ); + if count == 1 && filter.get("tag_ids_none").is_none() { + assert_eq!(result["items"][0]["tags"].as_array().unwrap().len(), 2); + } else if filter.get("tag_ids_none").is_some() { + assert_eq!(result["items"][0]["id"], other.id); + } + } + let renamed = post_json( + app.clone(), + &tags_path, + json!({ + "operation_key":"rename", "action":{"kind":"rename", "tag_id":bug_id, + "expected_last_operation_key":"create-bug", "name":"Defect"} + }), + ) + .await; + assert_eq!(renamed["id"], bug["id"]); + let shown = get_json(app.clone(), &ticket_path).await; + assert!( + shown["tags"] + .as_array() + .unwrap() + .iter() + .any(|tag| tag["name"] == "Defect") + ); + request_json( + app.clone(), + "POST", + &tags_path, + Some(json!({ + "operation_key":"stale-rename", "action":{"kind":"rename", "tag_id":bug_id, + "expected_last_operation_key":"create-bug", "name":"Wrong"} + })), + StatusCode::CONFLICT, + ) + .await; + post_json( + app.clone(), + &tags_path, + json!({ + "operation_key":"delete", "action":{"kind":"delete", "tag_id":bug_id, + "expected_last_operation_key":"rename"} + }), + ) + .await; + let deleted = get_json(app.clone(), &ticket_path).await; + assert_eq!(deleted["tags"].as_array().unwrap().len(), 1); + request_json( + app.clone(), + "PUT", + &format!("{ticket_path}/tags"), + Some(json!({ + "operation_key":"stale-set", "expected_tag_ids":[ready_id,bug_id], "tag_ids":[] + })), + StatusCode::CONFLICT, + ) + .await; + let untagged = request_json( + app.clone(), + "PUT", + &format!("{ticket_path}/tags"), + Some(json!({ + "operation_key":"detach", "expected_tag_ids":[ready_id], "tag_ids":[] + })), + StatusCode::OK, + ) + .await; + assert_eq!(untagged, json!([])); + let final_detail = get_json(app, &ticket_path).await; + assert_eq!(final_detail["state"], before["state"]); + assert_eq!(final_detail["content_digest"], before["content_digest"]); + let classified = backend.show(reference.id.clone().into()).unwrap(); + assert_eq!(classified.current_closure, reasoned.current_closure); + assert_eq!(classified.case_history, reasoned.case_history); + let tag_history: Vec<_> = classified + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .cloned() + .collect(); + let corrected = tag_test_case_update( + &backend, + &reference.id, + "correct-close", + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::Completed, + }, + ); + assert_eq!(corrected.tags, classified.tags); + assert_eq!(corrected.case_history[0], reasoned.case_history[0]); + let reopened = tag_test_case_update( + &backend, + &reference.id, + "reopen-case", + ticket::TicketCaseAction::Reopen, + ); + assert_eq!(reopened.tags, classified.tags); + assert_eq!( + reopened + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .cloned() + .collect::>(), + tag_history + ); + assert!(execution.protocol_methods().is_empty()); +} + +#[tokio::test] +async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_source() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let owner_token = seed_test_api_token(api.store.as_ref(), TEST_WORKSPACE_ID); + let collaborator_token = seed_test_api_token(api.store.as_ref(), "tags-collaborator"); + let app = build_router(api.clone()); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/ticket-tags"); + let input = json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}}); + request_json( + app.clone(), + "POST", + &path, + Some(input.clone()), + StatusCode::UNAUTHORIZED, + ) + .await; + + let tag = request_json_authenticated( + app.clone(), + "POST", + &path, + Some(input), + &owner_token, + StatusCode::OK, + ) + .await; + assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID); + // Classification follows shared Ticket Workspace write authority, not the + // owner-only repository/secrets-management policy. A distinct authenticated + // actor may manage tags but cannot replay another actor's receipt. + request_json_authenticated( + app.clone(), + "POST", + &path, + Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})), + &collaborator_token, + StatusCode::CONFLICT, + ) + .await; + request_json_authenticated( + app.clone(), + "POST", + "/api/w/other-workspace/ticket-tags", + Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})), + &collaborator_token, + StatusCode::NOT_FOUND, + ) + .await; + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri(&path) + .header( + axum::http::header::AUTHORIZATION, + format!("Bearer {owner_token}"), + ) + .header("x-yoi-runtime-id", "untrusted-runtime") + .header("x-yoi-worker-id", "untrusted-worker") + .header(CONTENT_TYPE, "application/json") + .body(Body::from( + json!({"operation_key":"forged", "action":{"kind":"create", "name":"forged"}}) + .to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + let status = response.status(); + let body = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::BAD_REQUEST, + "{}", + String::from_utf8_lossy(&body) + ); + let tags = browser_ticket_backend(&api) + .unwrap() + .list_workspace_tags(ticket::TagListRequest::default()) + .unwrap(); + assert_eq!(tags.len(), 1); +} + +#[tokio::test] +async fn tag_query_filters_precede_bounded_candidate_scan_and_bind_cursors() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + for i in 0..5 { + backend + .create(ticket::NewTicket::new(format!("a unrelated {i}"))) + .unwrap(); + } + let tag = backend + .mutate_workspace_tag(ticket::WorkspaceTagMutation { + operation_key: "create".into(), + action: ticket::WorkspaceTagAction::Create { name: "bug".into() }, + }) + .unwrap(); + for i in 0..3 { + let id = backend + .create(ticket::NewTicket::new(format!("z tagged {i}"))) + .unwrap() + .id; + backend + .set_ticket_tags( + id.into(), + ticket::TicketTagSetRequest { + expected_tag_ids: vec![], + tag_ids: vec![tag.id.clone()], + operation_key: format!("set-{i}"), + }, + ) + .unwrap(); + } + let app = build_inner_router(api); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"); + let mut input = json!({"tag_ids":[tag.id], "sort":"title", "limit":1}); + for i in 0..3 { + let page = post_json(app.clone(), &path, input.clone()).await; + assert_eq!(page["items"].as_array().unwrap().len(), 1); + assert_eq!(page["items"][0]["title"], format!("z tagged {i}")); + if i < 2 { + let cursor = page["page"]["next_cursor"].clone(); + request_json( + app.clone(), + "POST", + &path, + Some(json!({"tag_ids_any":[tag.id], "sort":"title", "limit":1, "cursor":cursor})), + StatusCode::NOT_FOUND, + ) + .await; + input["cursor"] = cursor; + } else { + assert_eq!(page["page"]["has_more"], false); + } + } +} diff --git a/crates/workspace-server/src/server_workspace_config.rs b/crates/workspace-server/src/server_workspace_config.rs index 5bec7ce6b..66cb7be04 100644 --- a/crates/workspace-server/src/server_workspace_config.rs +++ b/crates/workspace-server/src/server_workspace_config.rs @@ -53,7 +53,10 @@ pub(super) fn pre_error(error: impl Into) -> Failure { fn mutation_error(error: Error) -> Failure { // Storage/service failures at a mutation boundary may include a commit // acknowledgement failure. Never describe those as a guaranteed rollback. - if matches!(&error, Error::Sqlite(_) | Error::Store(_)) { + if matches!( + &error, + Error::Sqlite(_) | Error::Store(_) | Error::DbOperationOutcomeUnknown + ) { unknown() } else { pre_error(error) @@ -65,7 +68,7 @@ fn unknown() -> Failure { ..fail(500, "outcome_unknown") } } -pub(super) fn identity( +pub(super) async fn identity( api: &WorkspaceApi, context: &server_api::ServerRequestContext, workspace_id: &str, @@ -73,7 +76,9 @@ pub(super) fn identity( validate_workspace_scope(api, workspace_id).map_err(pre_error)?; let headers = current_worker_contract_headers(context).map_err(|_| fail(403, "access_denied"))?; - current_worker_identity(api, workspace_id, &headers).map_err(pre_error) + authenticate_worker_mutation_source_owned(api, workspace_id, &headers) + .await + .map_err(pre_error) } async fn lock( api: &WorkspaceApi, @@ -192,9 +197,23 @@ pub(super) async fn get( api: &WorkspaceApi, worker: &RuntimeWorkerRef, ) -> ConfigResult> { - let _guard = lock(api, worker).await?; - let grant = grant_for_worker(api, worker)?; - Ok(active_link(api, worker, &grant, None)?.map(|link| attachment(&grant, &link, true))) + let guard = lock(api, worker).await?; + let executor = api.store.db_executor(); + let api = api.clone(); + let worker = worker.clone(); + executor + .run(move || { + let _guard = guard; + Ok::<_, Error>((|| { + let grant = grant_for_worker(&api, &worker)?; + Ok::<_, Failure>( + active_link(&api, &worker, &grant, None)? + .map(|link| attachment(&grant, &link, true)), + ) + })()) + }) + .await + .map_err(pre_error)? } pub(super) async fn attach( api: &WorkspaceApi, @@ -204,46 +223,59 @@ pub(super) async fn attach( if request.alias.as_deref().is_some_and(|alias| alias != ALIAS) { return Err(fail(400, "invalid_alias")); } - let _guard = lock(api, worker).await?; - let grant = grant_for_worker(api, worker)?; - let access = request.access.unwrap_or(grant.access); - if access == Access::ReadWrite && grant.access != Access::ReadWrite { - return Err(fail(403, "access_denied")); - } - if let Some(mut link) = active_link(api, worker, &grant, None)? { - // Same active alias/identity is idempotent; explicit write never silently downgrades. - if request.access == Some(Access::ReadWrite) - && effective_access(&grant, &link) != Access::ReadWrite - { - return Err(fail(403, "access_denied")); - } - if access == Access::ReadOnly && effective_access(&grant, &link) != Access::ReadOnly { - link.capabilities = access_capabilities(access); - link = api - .store - .attach_worker_workdir(&link) - .map_err(mutation_error)?; - } - return Ok(attachment(&grant, &link, true)); - } - let link = api - .store - .attach_worker_workdir(&WorkerWorkdirLinkRecord { - connection_id: String::new(), - workspace_id: grant.workspace_id.clone(), - worker: worker.clone(), - workdir_id: grant.working_directory_id.clone(), - alias: ALIAS.into(), - capabilities: access_capabilities(access), - linked_at: now_registry_timestamp(), - unlinked_at: None, + let guard = lock(api, worker).await?; + let executor = api.store.db_executor(); + let api = api.clone(); + let worker = worker.clone(); + executor + .run(move || { + let _guard = guard; + Ok::<_, Error>((|| { + let grant = grant_for_worker(&api, &worker)?; + let access = request.access.unwrap_or(grant.access); + if access == Access::ReadWrite && grant.access != Access::ReadWrite { + return Err(fail(403, "access_denied")); + } + if let Some(mut link) = active_link(&api, &worker, &grant, None)? { + // Same active alias/identity is idempotent; explicit write never silently downgrades. + if request.access == Some(Access::ReadWrite) + && effective_access(&grant, &link) != Access::ReadWrite + { + return Err(fail(403, "access_denied")); + } + if access == Access::ReadOnly + && effective_access(&grant, &link) != Access::ReadOnly + { + link.capabilities = access_capabilities(access); + link = api + .store + .attach_worker_workdir(&link) + .map_err(mutation_error)?; + } + return Ok(attachment(&grant, &link, true)); + } + let link = api + .store + .attach_worker_workdir(&WorkerWorkdirLinkRecord { + connection_id: String::new(), + workspace_id: grant.workspace_id.clone(), + worker: worker.clone(), + workdir_id: grant.working_directory_id.clone(), + alias: ALIAS.into(), + capabilities: access_capabilities(access), + linked_at: now_registry_timestamp(), + unlinked_at: None, + }) + .map_err(mutation_error)?; + // No Runtime filesystem/session synchronization for a logical config target. + api.worker_projection + .refresh(&worker) + .map_err(|_| unknown())?; + Ok(attachment(&grant, &link, false)) + })()) }) - .map_err(mutation_error)?; - // No Runtime filesystem/session synchronization for a logical config target. - api.worker_projection - .refresh(worker) - .map_err(|_| unknown())?; - Ok(attachment(&grant, &link, false)) + .await + .map_err(mutation_error)? } fn resolve( api: &WorkspaceApi, @@ -319,9 +351,22 @@ pub(super) async fn observe( for path in &request.paths { path_valid(path, true)?; } - let _guard = lock(api, worker).await?; - let (grant, link) = resolve(api, worker, &request.connection_id)?; - let state = tree(api)?; + let guard = lock(api, worker).await?; + let resolution_api = api.clone(); + let resolution_worker = worker.clone(); + let connection_id = request.connection_id.clone(); + let (grant, link, state, _guard) = api + .store + .db_executor() + .run(move || { + Ok::<_, Error>((|| { + let (grant, link) = resolve(&resolution_api, &resolution_worker, &connection_id)?; + let state = tree(&resolution_api)?; + Ok::<_, Failure>((grant, link, state, guard)) + })()) + }) + .await + .map_err(pre_error)??; let schema_fingerprint = api .config_schema_registry .compose() @@ -433,9 +478,22 @@ pub(super) async fn read( request: WorkspaceConfigReadRequest, ) -> ConfigResult { path_valid(&request.path, false)?; - let _guard = lock(api, worker).await?; - let (grant, link) = resolve(api, worker, &request.connection_id)?; - let state = tree(api)?; + let guard = lock(api, worker).await?; + let resolution_api = api.clone(); + let resolution_worker = worker.clone(); + let connection_id = request.connection_id.clone(); + let (grant, link, state, _guard) = api + .store + .db_executor() + .run(move || { + Ok::<_, Error>((|| { + let (grant, link) = resolve(&resolution_api, &resolution_worker, &connection_id)?; + let state = tree(&resolution_api)?; + Ok::<_, Failure>((grant, link, state, guard)) + })()) + }) + .await + .map_err(pre_error)??; let schema_fingerprint = api .config_schema_registry .compose() @@ -488,12 +546,25 @@ pub(super) async fn commit( server_api::ConfigTreeChange::Delete { path, .. } => path_valid(path, false)?, } } - let _guard = lock(api, worker).await?; - let (grant, link) = resolve(api, worker, &request.connection_id)?; - if effective_access(&grant, &link) != Access::ReadWrite { - return Err(fail(403, "access_denied")); - } - let state = tree(api)?; + let guard = lock(api, worker).await?; + let resolution_api = api.clone(); + let resolution_worker = worker.clone(); + let connection_id = request.connection_id.clone(); + let (grant, link, state, guard) = api + .store + .db_executor() + .run(move || { + Ok::<_, Error>((|| { + let (grant, link) = resolve(&resolution_api, &resolution_worker, &connection_id)?; + if effective_access(&grant, &link) != Access::ReadWrite { + return Err(fail(403, "access_denied")); + } + let state = tree(&resolution_api)?; + Ok::<_, Failure>((grant, link, state, guard)) + })()) + }) + .await + .map_err(pre_error)??; let schema_fingerprint = api .config_schema_registry .compose() @@ -507,17 +578,19 @@ pub(super) async fn commit( // Evaluation/semantic validation are pure preparation. Bound this stage // without ever dispatching persistence in a timed-out background task. let preparation_api = api.clone(); - let preparation = tokio::task::spawn_blocking(move || { - prepare_workspace_config_tree( + let executor = api.store.db_executor(); + let preparation = executor.run(move || { + // Timeout/cancellation releases exclusion only after actual preparation ends. + let candidate = prepare_workspace_config_tree( &preparation_api, &preparation_api.config.workspace_id, &canonical, - ) + )?; + Ok::<_, Error>((candidate, guard)) }); - let candidate = tokio::time::timeout(DEADLINE, preparation) + let (candidate, guard) = tokio::time::timeout(DEADLINE, preparation) .await .map_err(|_| fail(503, "evaluation_timeout"))? - .map_err(|_| fail(500, "evaluation_unavailable"))? .map_err(pre_error)?; if candidate.base_digest != state.snapshot.digest || candidate.base_toolchain_fingerprint != state.contract.fingerprint @@ -526,15 +599,78 @@ pub(super) async fn commit( { return Err(fail(409, "stale_validator")); } - // Recheck durable authority before the first auxiliary/canonical effects. - let (current_grant, current_link) = resolve(api, worker, &request.connection_id)?; - if current_grant != grant || current_link.capabilities != link.capabilities { - return Err(fail(409, "authority_changed")); + let persistence_api = api.clone(); + let worker = worker.clone(); + let connection_id = request.connection_id.clone(); + let expected_grant = grant.clone(); + let expected_link = link.clone(); + let (state, publication) = api + .store + .db_executor() + .run(move || { + Ok::<_, Error>((|| { + let (current_grant, current_link) = + resolve(&persistence_api, &worker, &connection_id)?; + if current_grant != expected_grant + || current_link.capabilities != expected_link.capabilities + { + return Err(fail(409, "authority_changed")); + } + // Preserve the shared helper's ordering and atomic CAS, but never + // run its remote Runtime publication on the sole DB worker. + project_repository_access_candidate( + &*persistence_api.store, + &persistence_api.repository_secrets, + &persistence_api.config.workspace_id, + &candidate, + ) + .map_err(|_| unknown())?; + let state = persistence_api + .config_store + .commit_evaluated_workspace_config( + &persistence_api.config.workspace_id, + &candidate, + ) + .map_err(|_| unknown())?; + let publication = persistence_api + .prompt_projection_cache + .resolve(&persistence_api.config.workspace_id, &state) + .ok() + .and_then(|projection| { + // Dispatch from the actual persistence owner even if its + // waiter was cancelled. Runtime I/O owns exclusion, not + // a DB worker or an open SQL transaction. + let (complete, publication) = tokio::sync::oneshot::channel(); + let runtime = persistence_api.runtime.clone(); + let admission = persistence_api.host_executors.host().and_then(|executor| { + executor.dispatch(move || { + let _guard = guard; + let _diagnostics = runtime + .observe_workspace_prompt_projection((*projection).clone()); + let _ = complete.send(()); + }) + }); + match admission { + Ok(()) => Some(publication), + Err(error) => { + tracing::warn!( + workspace_id = %persistence_api.config.workspace_id, + %error, + "Workspace config committed but Runtime publication was not admitted" + ); + None + } + } + }); + Ok((state, publication)) + })()) + }) + .await + .map_err(mutation_error)??; + if let Some(publication) = publication { + // Publication is best effort and cannot turn an acknowledged CAS into an error. + let _ = publication.await; } - // From this boundary repository credential projection may have side effects. - // Never falsely claim rollback or automatically replay a failed/uncertain save. - let state = persist_workspace_config_candidate(api, &api.config.workspace_id, &candidate) - .map_err(|_| unknown())?; Ok(WorkspaceConfigCommitResponse { validator: validator(&grant, &link, &state, &schema_fingerprint, ""), digest: state.snapshot.digest, @@ -550,37 +686,61 @@ pub(super) async fn create_grant( .await .map_err(pre_error)?; let worker = RuntimeWorkerRef::new(&request.runtime_id, &request.worker_id); - let _guard = lock(api, &worker).await?; - let summary = api - .runtime - .worker(&worker) - .map_err(|_| fail(404, "worker_not_found"))?; + let guard = lock(api, &worker).await?; + let runtime = api.runtime.clone(); + let lookup_worker = worker.clone(); + let (summary, guard) = api + .host_executors + .host() + .map_err(pre_error)? + .run(move || { + Ok::<_, Error>((|| { + let summary = runtime + .worker(&lookup_worker) + .map_err(|_| fail(404, "worker_not_found"))?; + Ok::<_, Failure>((summary, guard)) + })()) + }) + .await + .map_err(pre_error)??; if summary.workspace.workspace_id.as_deref() != Some(workspace_id) { return Err(fail(403, "access_denied")); } - if let Some(grant) = api - .store - .current_workspace_config_grant(workspace_id, &worker) - .map_err(pre_error)? - { - if grant.access != request.access { - return Err(fail(409, "grant_exists_revoke_first")); - } - return Ok(grant); - } - let grant = WorkspaceConfigGrantResponse { - grant_id: Uuid::now_v7().to_string(), - workspace_id: workspace_id.into(), - runtime_id: worker.runtime_id, - worker_id: worker.worker_id, - working_directory_id: Uuid::now_v7().to_string(), - access: request.access, - revoked: false, - }; - api.store - .create_workspace_config_grant(&grant, &actor.account_id) - .map_err(mutation_error)?; - Ok(grant) + let executor = api.store.db_executor(); + let api = api.clone(); + let workspace_id = workspace_id.to_owned(); + let account_id = actor.account_id.clone(); + executor + .run(move || { + let _guard = guard; + Ok::<_, Error>((|| { + if let Some(grant) = api + .store + .current_workspace_config_grant(&workspace_id, &worker) + .map_err(pre_error)? + { + if grant.access != request.access { + return Err(fail(409, "grant_exists_revoke_first")); + } + return Ok(grant); + } + let grant = WorkspaceConfigGrantResponse { + grant_id: Uuid::now_v7().to_string(), + workspace_id, + runtime_id: worker.runtime_id, + worker_id: worker.worker_id, + working_directory_id: Uuid::now_v7().to_string(), + access: request.access, + revoked: false, + }; + api.store + .create_workspace_config_grant(&grant, &account_id) + .map_err(mutation_error)?; + Ok(grant) + })()) + }) + .await + .map_err(mutation_error)? } pub(super) async fn revoke_grant( api: &WorkspaceApi, @@ -591,39 +751,60 @@ pub(super) async fn revoke_grant( require_workspace_owner(api, workspace_id, actor, "Workspace config grants") .await .map_err(pre_error)?; + let lookup_api = api.clone(); + let lookup_workspace_id = workspace_id.to_owned(); + let lookup_grant_id = grant_id.to_owned(); let mut grant = api .store - .get_workspace_config_grant(workspace_id, grant_id) + .db_executor() + .run(move || { + lookup_api + .store + .get_workspace_config_grant(&lookup_workspace_id, &lookup_grant_id) + }) + .await .map_err(pre_error)? .ok_or_else(|| fail(404, "not_found"))?; let worker = RuntimeWorkerRef::new(&grant.runtime_id, &grant.worker_id); - let _guard = lock(api, &worker).await?; - api.store - .revoke_workspace_config_grant(workspace_id, grant_id) - .map_err(mutation_error)?; - grant.revoked = true; - // Revocation is durable before unlink; a failed cleanup cannot restore access. - if let Some(link) = api - .store - .list_workdir_worker_links(workspace_id, &grant.working_directory_id) - .map_err(|_| unknown())? - .into_iter() - .find(|link| link.unlinked_at.is_none() && link.worker == worker) - { - api.store - .detach_worker_workdir_connection( - workspace_id, - &worker, - &link.alias, - &link.connection_id, - &now_registry_timestamp(), - ) - .map_err(|_| unknown())?; - } - api.worker_projection - .refresh(&worker) - .map_err(|_| unknown())?; - Ok(grant) + let guard = lock(api, &worker).await?; + let executor = api.store.db_executor(); + let api = api.clone(); + let workspace_id = workspace_id.to_owned(); + let grant_id = grant_id.to_owned(); + executor + .run(move || { + let _guard = guard; + Ok::<_, Error>((|| { + api.store + .revoke_workspace_config_grant(&workspace_id, &grant_id) + .map_err(mutation_error)?; + grant.revoked = true; + // Revocation is durable before unlink; a failed cleanup cannot restore access. + if let Some(link) = api + .store + .list_workdir_worker_links(&workspace_id, &grant.working_directory_id) + .map_err(|_| unknown())? + .into_iter() + .find(|link| link.unlinked_at.is_none() && link.worker == worker) + { + api.store + .detach_worker_workdir_connection( + &workspace_id, + &worker, + &link.alias, + &link.connection_id, + &now_registry_timestamp(), + ) + .map_err(|_| unknown())?; + } + api.worker_projection + .refresh(&worker) + .map_err(|_| unknown())?; + Ok(grant) + })()) + }) + .await + .map_err(mutation_error)? } pub(super) fn summary( @@ -661,7 +842,11 @@ pub(super) fn summary( mod tests { use super::*; #[test] - fn workspace_config_mutation_storage_failures_are_conservatively_unknown() { + fn workspace_config_mutation_storage_or_executor_failures_are_conservatively_unknown() { + assert_eq!( + mutation_error(Error::DbOperationOutcomeUnknown).classification, + Classification::Unknown + ); assert_eq!( mutation_error(Error::Sqlite(rusqlite::Error::InvalidQuery)).classification, Classification::Unknown diff --git a/crates/workspace-server/src/server_workspace_config_tests.rs b/crates/workspace-server/src/server_workspace_config_tests.rs index ac11e7766..1c932be1a 100644 --- a/crates/workspace-server/src/server_workspace_config_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_tests.rs @@ -318,7 +318,7 @@ mod workspace_config_integration { }; for scoped in [false, true] { let app = if scoped { - workspace_server_router(WorkspaceServerApi::new( + workspace_server_router(WorkspaceServerApi::new_for_test( api.config.clone(), api.store.clone(), )) @@ -803,9 +803,9 @@ mod workspace_config_integration { .supports(workdir::WorkdirSessionCapability::Command) ); let lock = current_worker_session_lock(api, &fixture.worker); - let _guard = lock.lock().await; + let _guard = Arc::new(lock.lock_owned().await); assert!( - open_current_worker_workdir_session_locked(api, &fixture.worker, &link) + open_current_worker_workdir_session_locked(api, &fixture.worker, &link, &_guard) .await .is_err() ); @@ -1385,7 +1385,7 @@ mod workspace_config_integration { let api = &fixture.api; let granted = grant(api, &fixture.worker, Access::ReadWrite).await; let attached = attach(api, &fixture.worker).await; - let restarted = SqliteWorkspaceStore::open(&api.config.database_path).unwrap(); + let restarted = SqliteWorkspaceStore::open_for_test(&api.config.database_path).unwrap(); assert_eq!( restarted .get_workspace_config_grant(TEST_WORKSPACE_ID, &granted.grant_id) diff --git a/crates/workspace-server/src/server_workspace_config_wip_tests.rs b/crates/workspace-server/src/server_workspace_config_wip_tests.rs index b5a229f55..1e989edd8 100644 --- a/crates/workspace-server/src/server_workspace_config_wip_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_wip_tests.rs @@ -800,7 +800,7 @@ async fn workspace_config_actual_remote_and_embedded_clients_use_authenticated_w let base = format!("http://{addr}"); let mut config = test_server_config(root.path()); config.backend_base_url = Some("server-test".into()); - let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap()); let captured = Arc::new(Mutex::new(None)); let abort = Arc::new(Mutex::new(None)); let factory = ConfigCaptureFactory { diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index b7c2b6dc3..ed8e673a1 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -25,6 +25,11 @@ //! The internal Restore journal records client intent with a request ID and observation token //! fixed before sending. Unknown outcomes and restarts reuse that pair, settling on a confirmed //! result. This is not a second lifecycle state machine duplicating Runtime operations or receipts. +//! +//! Ticket composes case judgments, classification and delegation/handling independently of +//! operational work/delivery/start fences. Migration retains immutable legacy snapshots and +//! receipts, never promotes legacy done to fulfillment or unknown handling to a fresh request, +//! and never starts Workers. Existing case/tag histories and auth archives survive composition. use std::collections::{BTreeSet, HashMap, HashSet}; use std::path::Path; @@ -55,8 +60,20 @@ use crate::backend_job::{ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; +#[path = "assignment_delivery.rs"] +mod assignment_delivery; +#[path = "assignment_tracking.rs"] +mod assignment_tracking; +pub use assignment_delivery::TicketAssignmentDelivery; +pub use assignment_tracking::TicketOrchestratorAssignmentSourceContext; +pub use ticket::{ + TicketAssignmentTracking, TicketAssignmentTrackingHistoryEntry, + TicketAssignmentTrackingProjection, TicketAssignmentTrackingSourceContext, + TicketAssignmentTrackingStatus, TicketAssignmentTrackingUpdate, +}; + const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 90; +const LATEST_SCHEMA_VERSION: i64 = 93; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -314,6 +331,21 @@ const MIGRATIONS: &[Migration] = &[ name: "Runtime binding connection configuration and historical verification archive", apply: migrate_runtime_binding_connections_v89_to_v90, }, + Migration { + version: 91, + name: "Ticket open/closed cases and retained routing evidence", + apply: migrate_ticket_cases_v90_to_v91, + }, + Migration { + version: 92, + name: "Ticket-owned assignment tracking", + apply: migrate_ticket_tracking_v91_to_v92, + }, + Migration { + version: 93, + name: "Durable assignment deliveries and source-bound startup fences", + apply: migrate_assignment_delivery_v92_to_v93, + }, ]; #[derive(Clone, Copy)] @@ -1132,6 +1164,14 @@ impl WorkspaceResourceKind { #[async_trait] pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { + /// Bounded owner for whole synchronous operations from async callers. Move owned inputs + /// and services into one closure covering connection/mutex waits, source authorization, + /// receipts, CAS, audit, commit and ordered publication. Do not split checks around awaits + /// or introduce a read pool; Runtime/network waits use the separate Host lane. + fn db_executor(&self) -> crate::db_executor::DbExecutor { + crate::db_executor::DbExecutor::shared() + } + async fn schema_version(&self) -> Result; fn resource_key( &self, @@ -1813,6 +1853,52 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { request_fingerprint: &str, created_at: &str, ) -> Result; + /// Common scan for committed current delegation inputs, not handling status. + fn list_pending_ticket_assignment_deliveries( + &self, + workspace_id: &str, + limit: usize, + ) -> Result>; + /// Frozen payload/request identity for exact response-loss retry. + fn reserve_ticket_assignment_delivery( + &self, + workspace_id: &str, + assignment_id: &str, + input_event_id: &str, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result; + /// Freeze exact rendered Runtime request bytes before dispatch. + fn prepare_ticket_assignment_delivery_content( + &self, + workspace_id: &str, + delivery_id: &str, + content: &str, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result; + fn ack_ticket_assignment_delivery( + &self, + workspace_id: &str, + delivery_id: &str, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result<()>; + fn reserve_ticket_assignment_operation_for_orchestrator( + &self, + workspace_id: &str, + operation_id: &str, + ticket_id: &str, + runtime_id: &str, + worker_id: Option<&str>, + request_fingerprint: &str, + created_at: &str, + orchestrator_assignment_id: &str, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result; + /// Recovery/pre-dispatch check; write boundaries also revalidate this fence. + fn validate_ticket_assignment_operation_fence( + &self, + workspace_id: &str, + operation_id: &str, + ) -> Result<()>; fn bind_ticket_assignment_operation_worker( &self, workspace_id: &str, @@ -1844,6 +1930,62 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ) -> Result>; /// Retained responsibility, including ended work and removed principals. /// Use the active variants for admission, routing, authorization or removal. + fn page_ticket_assignments( + &self, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, + ) -> Result; + fn page_ticket_assignment_diagnostics( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_input_event_id: Option<&str>, + after_start_operation_id: Option<&str>, + limit: usize, + ) -> Result; + fn answer_ticket_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + input: &ticket::TicketAssignmentAnswer, + source: &TicketOrchestratorAssignmentSourceContext, + actor: &str, + ) -> Result; + fn update_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result; + fn get_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result>; + fn list_current_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result>; + fn page_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_event_id: Option<&str>, + limit: usize, + ) -> Result; + fn list_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result>; fn list_current_ticket_role_assignments( &self, workspace_id: &str, @@ -1860,6 +2002,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ticket_id: &str, role: TicketAssignmentRole, ) -> Result>; + fn set_current_ticket_orchestrator_assignment( + &self, + record: &TicketRoleAssignmentRecord, + expected_assignment_id: Option<&str>, + event_id: &str, + operation_id: &str, + allow_reassign: bool, + source: &TicketOrchestratorAssignmentSourceContext, + ) -> Result; + fn clear_current_ticket_orchestrator_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + event_id: &str, + operation_id: &str, + actor: &str, + occurred_at: &str, + reason: Option<&str>, + source: &TicketOrchestratorAssignmentSourceContext, + ) -> Result; fn set_current_ticket_role_assignment( &self, record: &TicketRoleAssignmentRecord, @@ -2135,6 +2298,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { #[derive(Clone)] pub struct SqliteWorkspaceStore { conn: Arc>, + db_executor: crate::db_executor::DbExecutor, } impl SqliteWorkspaceStore { @@ -2175,12 +2339,50 @@ impl SqliteWorkspaceStore { } pub fn from_connection(conn: Connection) -> Result { + Self::from_connection_with_executor(conn, crate::db_executor::DbExecutor::shared()) + } + + fn from_connection_with_executor( + conn: Connection, + db_executor: crate::db_executor::DbExecutor, + ) -> Result { prepare_connection(&conn)?; Ok(Self { conn: Arc::new(Mutex::new(conn)), + db_executor, }) } + // Ordinary fixtures own the same finite budget as production, not another + // fixture's admission/SQL/drain lifetime or a permanent global worker. + #[cfg(test)] + pub(crate) fn open_for_test(path: impl AsRef) -> Result { + Self::from_connection_for_test(Connection::open(path)?) + } + + #[cfg(test)] + pub(crate) fn in_memory_for_test() -> Result { + Self::from_connection_for_test(Connection::open_in_memory()?) + } + + #[cfg(test)] + pub(crate) fn from_connection_for_test(conn: Connection) -> Result { + Self::from_connection_with_executor( + conn, + crate::db_executor::DbExecutor::new(crate::db_executor::DbExecutorLimits::default())?, + ) + } + + /// Override execution limits for a hermetic fixture; subsequent clones share it. + pub fn with_db_executor(mut self, executor: crate::db_executor::DbExecutor) -> Self { + self.db_executor = executor; + self + } + + pub fn db_executor(&self) -> crate::db_executor::DbExecutor { + self.db_executor.clone() + } + pub(crate) fn with_conn(&self, f: impl FnOnce(&Connection) -> Result) -> Result { let conn = self .conn @@ -2200,12 +2402,19 @@ impl SqliteWorkspaceStore { f(&mut conn) } - pub(crate) fn with_transaction( + /// Acquire SQLite write ownership before the first authority/journal read (Restore and + /// Workspace deletion). A DEFERRED read-to-write upgrade can return BUSY without invoking + /// the busy handler. Offload this whole helper; never automatically retry the mutation. + pub(crate) fn with_immediate_transaction( &self, f: impl FnOnce(&rusqlite::Transaction<'_>) -> Result, ) -> Result { self.with_conn_mut(|conn| { - let tx = conn.transaction()?; + // These callers read authority/journal state before mutating it. + // Acquire write ownership before the first read: a DEFERRED read + // upgrade against another connection's writer can return BUSY + // without invoking the busy handler. Never retry the domain mutation. + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; let value = f(&tx)?; tx.commit()?; Ok(value) @@ -2266,7 +2475,7 @@ impl SqliteWorkspaceStore { observed_token: Option<&str>, explicit_request_id: Option<&str>, ) -> Result { - self.with_transaction(|tx| { + self.with_immediate_transaction(|tx| { let existing: Option<(String, String)> = if let Some(id) = explicit_request_id { tx.query_row("SELECT request_id, expected_token FROM worker_restore_intents WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 AND request_id=?4", params![workspace_id, worker.runtime_id, worker.worker_id, id], |row| Ok((row.get(0)?, row.get(1)?))).optional()? @@ -3750,8 +3959,16 @@ fn insert_workspace_runtime_binding_audit( #[async_trait] impl ControlPlaneStore for SqliteWorkspaceStore { + fn db_executor(&self) -> crate::db_executor::DbExecutor { + self.db_executor.clone() + } + async fn schema_version(&self) -> Result { - self.with_conn(current_schema_version) + let store = self.clone(); + + self.db_executor + .run(move || store.with_conn(current_schema_version)) + .await } fn resource_key( @@ -3883,100 +4100,114 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } async fn upsert_workspace(&self, record: &WorkspaceRecord) -> Result<()> { - self.with_conn_mut(|conn| { - let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - #[cfg(test)] - if record.owner_account_id == "owner-account" { - // Unrelated store tests use this explicit fixture identity. Keep the - // production owner contract strict while giving those fixtures a real - // User Account row instead of reviving ownerless Workspace setup. - tx.execute( - "INSERT OR IGNORE INTO accounts ( - account_id, kind, handle, display_name, created_at, updated_at - ) VALUES (?1, 'user', 'owner-account', 'Owner Account', ?2, ?2)", - params![record.owner_account_id.as_str(), record.created_at.as_str()], - )?; - } - let owner_kind = tx - .query_row( - "SELECT kind FROM accounts WHERE account_id = ?1", - params![record.owner_account_id.as_str()], - |row| row.get::<_, String>(0), - ) - .optional()?; - if owner_kind.as_deref() != Some("user") { - return Err(Error::Store(format!( - "Workspace owner `{}` must reference an existing User Account", - record.owner_account_id - ))); - } - let current_owner = tx - .query_row( - "SELECT owner_account_id FROM workspaces WHERE workspace_id = ?1", - params![record.workspace_id.as_str()], - |row| row.get::<_, String>(0), - ) - .optional()?; - if current_owner - .as_deref() - .is_some_and(|owner| owner != record.owner_account_id) - { - return Err(Error::Store(format!( - "Workspace `{}` owner is immutable through upsert", - record.workspace_id - ))); - } - 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) - ON CONFLICT(workspace_id) DO UPDATE SET - display_name = excluded.display_name, - state = excluded.state, - updated_at = excluded.updated_at"#, - params![ - record.workspace_id, - record.owner_account_id, - record.display_name, - record.state, - record.created_at, - record.updated_at, - ], - )?; - tx.execute( - r#"INSERT OR IGNORE INTO workspace_memory_settings ( - workspace_id, language, created_at, updated_at - ) VALUES (?1, 'English', ?2, ?3)"#, - params![record.workspace_id, record.created_at, record.updated_at], - )?; - tx.execute( - r#"INSERT OR IGNORE INTO workspace_signing_identities ( - workspace_id, key_id, algorithm, public_key, public_key_fingerprint, - private_material_ref, state, created_at, provisioned_at, updated_at - ) VALUES ( - ?1, 'WK-' || lower(hex(randomblob(16))), 'ed25519', NULL, NULL, - 'workspace-signing/' || ?1 || '/ed25519-v1', - 'pending_provisioning', ?2, NULL, ?3 - )"#, - params![record.workspace_id, record.created_at, record.updated_at], - )?; - tx.commit()?; - Ok(()) - })?; - self.materialize_workspace_config(&record.workspace_id, &record.created_at) + let store = self.clone(); + let record = record.clone(); + self.db_executor + .run(move || { + let store = &store; + let record = &record; + store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + #[cfg(test)] + if record.owner_account_id == "owner-account" { + // Unrelated store tests use this explicit fixture identity. Keep the + // production owner contract strict while giving those fixtures a real + // User Account row instead of reviving ownerless Workspace setup. + tx.execute( + "INSERT OR IGNORE INTO accounts ( + account_id, kind, handle, display_name, created_at, updated_at + ) VALUES (?1, 'user', 'owner-account', 'Owner Account', ?2, ?2)", + params![record.owner_account_id.as_str(), record.created_at.as_str()], + )?; + } + let owner_kind = tx + .query_row( + "SELECT kind FROM accounts WHERE account_id = ?1", + params![record.owner_account_id.as_str()], + |row| row.get::<_, String>(0), + ) + .optional()?; + if owner_kind.as_deref() != Some("user") { + return Err(Error::Store(format!( + "Workspace owner `{}` must reference an existing User Account", + record.owner_account_id + ))); + } + let current_owner = tx + .query_row( + "SELECT owner_account_id FROM workspaces WHERE workspace_id = ?1", + params![record.workspace_id.as_str()], + |row| row.get::<_, String>(0), + ) + .optional()?; + if current_owner + .as_deref() + .is_some_and(|owner| owner != record.owner_account_id) + { + return Err(Error::Store(format!( + "Workspace `{}` owner is immutable through upsert", + record.workspace_id + ))); + } + 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) + ON CONFLICT(workspace_id) DO UPDATE SET + display_name = excluded.display_name, + state = excluded.state, + updated_at = excluded.updated_at"#, + params![ + record.workspace_id, + record.owner_account_id, + record.display_name, + record.state, + record.created_at, + record.updated_at, + ], + )?; + tx.execute( + r#"INSERT OR IGNORE INTO workspace_memory_settings ( + workspace_id, language, created_at, updated_at + ) VALUES (?1, 'English', ?2, ?3)"#, + params![record.workspace_id, record.created_at, record.updated_at], + )?; + tx.execute( + r#"INSERT OR IGNORE INTO workspace_signing_identities ( + workspace_id, key_id, algorithm, public_key, public_key_fingerprint, + private_material_ref, state, created_at, provisioned_at, updated_at + ) VALUES ( + ?1, 'WK-' || lower(hex(randomblob(16))), 'ed25519', NULL, NULL, + 'workspace-signing/' || ?1 || '/ed25519-v1', + 'pending_provisioning', ?2, NULL, ?3 + )"#, + params![record.workspace_id, record.created_at, record.updated_at], + )?; + tx.commit()?; + Ok(()) + })?; + store.materialize_workspace_config(&record.workspace_id, &record.created_at) + }) + .await } async fn get_workspace(&self, workspace_id: &str) -> Result> { - self.with_conn(|conn| { - conn.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, - ) - .optional() - .map_err(Error::from) - }) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + self.db_executor.run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + store.with_conn(|conn| { + conn.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, + ) + .optional() + .map_err(Error::from) + }) + }).await } async fn update_workspace_display_name( @@ -3985,58 +4216,68 @@ impl ControlPlaneStore for SqliteWorkspaceStore { expected_updated_at: &str, display_name: &str, ) -> Result> { - validate_identifier("workspace_id", workspace_id)?; - validate_non_empty("expected_updated_at", expected_updated_at)?; - validate_non_empty("display_name", display_name)?; - self.with_conn_mut(|conn| { - let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let current = 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, - ) - .optional()?; - let Some(current) = current else { - tx.commit()?; - return Ok(None); - }; - if current.updated_at != expected_updated_at { - tx.commit()?; - return Ok(None); - } - if current.display_name == display_name { - tx.commit()?; - return Ok(Some(current)); - } + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let expected_updated_at = expected_updated_at.to_owned(); + let display_name = display_name.to_owned(); + self.db_executor.run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let expected_updated_at = expected_updated_at.as_str(); + let display_name = display_name.as_str(); + validate_identifier("workspace_id", workspace_id)?; + validate_non_empty("expected_updated_at", expected_updated_at)?; + validate_non_empty("display_name", display_name)?; + store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + let current = 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, + ) + .optional()?; + let Some(current) = current else { + tx.commit()?; + return Ok(None); + }; + if current.updated_at != expected_updated_at { + tx.commit()?; + return Ok(None); + } + if current.display_name == display_name { + tx.commit()?; + return Ok(Some(current)); + } - let now = chrono::Utc::now(); - let updated_at = chrono::DateTime::parse_from_rfc3339(¤t.updated_at) - .ok() - .map(|previous| previous.with_timezone(&chrono::Utc)) - .filter(|previous| *previous >= now) - .map(|previous| previous + chrono::Duration::nanoseconds(1)) - .unwrap_or(now) - .to_rfc3339_opts(chrono::SecondsFormat::Nanos, true); - let changed = tx.execute( - r#"UPDATE workspaces - SET display_name = ?3, updated_at = ?4 - WHERE workspace_id = ?1 AND updated_at = ?2"#, - params![workspace_id, expected_updated_at, display_name, updated_at], - )?; - if changed != 1 { + let now = chrono::Utc::now(); + let updated_at = chrono::DateTime::parse_from_rfc3339(¤t.updated_at) + .ok() + .map(|previous| previous.with_timezone(&chrono::Utc)) + .filter(|previous| *previous >= now) + .map(|previous| previous + chrono::Duration::nanoseconds(1)) + .unwrap_or(now) + .to_rfc3339_opts(chrono::SecondsFormat::Nanos, true); + let changed = tx.execute( + r#"UPDATE workspaces + SET display_name = ?3, updated_at = ?4 + WHERE workspace_id = ?1 AND updated_at = ?2"#, + params![workspace_id, expected_updated_at, display_name, updated_at], + )?; + if changed != 1 { + tx.commit()?; + return Ok(None); + } + let updated = WorkspaceRecord { + display_name: display_name.to_string(), + updated_at, + ..current + }; tx.commit()?; - return Ok(None); - } - let updated = WorkspaceRecord { - display_name: display_name.to_string(), - updated_at, - ..current - }; - tx.commit()?; - Ok(Some(updated)) - }) + Ok(Some(updated)) + }) + }).await } fn create_workspace_bootstrap( @@ -4556,7 +4797,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, runtime_id: &str, ) -> Result> { - SqliteWorkspaceStore::get_workspace_runtime_binding(self, workspace_id, runtime_id) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let runtime_id = runtime_id.as_str(); + SqliteWorkspaceStore::get_workspace_runtime_binding(store, workspace_id, runtime_id) + }) + .await } async fn list_workspace_runtime_bindings( @@ -4564,7 +4815,19 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, include_revoked: bool, ) -> Result> { - SqliteWorkspaceStore::list_workspace_runtime_bindings(self, workspace_id, include_revoked) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + SqliteWorkspaceStore::list_workspace_runtime_bindings( + store, + workspace_id, + include_revoked, + ) + }) + .await } async fn has_other_active_workspace_runtime_binding( @@ -4572,11 +4835,21 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, runtime_id: &str, ) -> Result { - SqliteWorkspaceStore::has_other_active_workspace_runtime_binding( - self, - workspace_id, - runtime_id, - ) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let runtime_id = runtime_id.as_str(); + SqliteWorkspaceStore::has_other_active_workspace_runtime_binding( + store, + workspace_id, + runtime_id, + ) + }) + .await } async fn delete_workspace_runtime_binding( @@ -4584,7 +4857,21 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, runtime_id: &str, ) -> Result { - SqliteWorkspaceStore::delete_workspace_runtime_binding(self, workspace_id, runtime_id) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let runtime_id = runtime_id.as_str(); + SqliteWorkspaceStore::delete_workspace_runtime_binding( + store, + workspace_id, + runtime_id, + ) + }) + .await } async fn reserve_runtime_removal( @@ -4596,22 +4883,48 @@ impl ControlPlaneStore for SqliteWorkspaceStore { expected_binding_id: &str, config_digest: &str, ) -> Result { - SqliteWorkspaceStore::reserve_runtime_removal( - self, - workspace_id, - runtime_id, - operation_id, - request_fingerprint, - expected_binding_id, - config_digest, - ) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + let operation_id = operation_id.to_owned(); + let request_fingerprint = request_fingerprint.to_owned(); + let expected_binding_id = expected_binding_id.to_owned(); + let config_digest = config_digest.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let runtime_id = runtime_id.as_str(); + let operation_id = operation_id.as_str(); + let request_fingerprint = request_fingerprint.as_str(); + let expected_binding_id = expected_binding_id.as_str(); + let config_digest = config_digest.as_str(); + SqliteWorkspaceStore::reserve_runtime_removal( + store, + workspace_id, + runtime_id, + operation_id, + request_fingerprint, + expected_binding_id, + config_digest, + ) + }) + .await } async fn get_runtime_removal( &self, operation_id: &str, ) -> Result> { - SqliteWorkspaceStore::get_runtime_removal(self, operation_id) + let store = self.clone(); + let operation_id = operation_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let operation_id = operation_id.as_str(); + SqliteWorkspaceStore::get_runtime_removal(store, operation_id) + }) + .await } async fn mark_runtime_removal_failed( @@ -4619,14 +4932,36 @@ impl ControlPlaneStore for SqliteWorkspaceStore { operation_id: &str, failure_category: &str, ) -> Result { - SqliteWorkspaceStore::mark_runtime_removal_failed(self, operation_id, failure_category) + let store = self.clone(); + let operation_id = operation_id.to_owned(); + let failure_category = failure_category.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let operation_id = operation_id.as_str(); + let failure_category = failure_category.as_str(); + SqliteWorkspaceStore::mark_runtime_removal_failed( + store, + operation_id, + failure_category, + ) + }) + .await } async fn commit_runtime_binding_removal( &self, operation_id: &str, ) -> Result { - SqliteWorkspaceStore::commit_runtime_binding_removal(self, operation_id) + let store = self.clone(); + let operation_id = operation_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let operation_id = operation_id.as_str(); + SqliteWorkspaceStore::commit_runtime_binding_removal(store, operation_id) + }) + .await } async fn complete_runtime_removal( @@ -4634,18 +4969,34 @@ impl ControlPlaneStore for SqliteWorkspaceStore { operation_id: &str, runtime_registration_removed: bool, ) -> Result { - SqliteWorkspaceStore::complete_runtime_removal( - self, - operation_id, - runtime_registration_removed, - ) + let store = self.clone(); + let operation_id = operation_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let operation_id = operation_id.as_str(); + SqliteWorkspaceStore::complete_runtime_removal( + store, + operation_id, + runtime_registration_removed, + ) + }) + .await } async fn list_resumable_runtime_removals( &self, workspace_id: &str, ) -> Result> { - SqliteWorkspaceStore::list_resumable_runtime_removals(self, workspace_id) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + SqliteWorkspaceStore::list_resumable_runtime_removals(store, workspace_id) + }) + .await } async fn upsert_workspace_runtime_binding_record( @@ -4653,7 +5004,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record: WorkspaceRuntimeBinding, replace: bool, ) -> Result { - SqliteWorkspaceStore::upsert_workspace_runtime_binding(self, record, replace) + let store = self.clone(); + + self.db_executor + .run(move || { + let store = &store; + SqliteWorkspaceStore::upsert_workspace_runtime_binding(store, record, replace) + }) + .await } async fn revoke_workspace_runtime_binding_record( @@ -4662,12 +5020,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore { runtime_id: &str, revoked_at: &str, ) -> Result { - SqliteWorkspaceStore::revoke_workspace_runtime_binding( - self, - workspace_id, - runtime_id, - revoked_at, - ) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + let revoked_at = revoked_at.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let runtime_id = runtime_id.as_str(); + let revoked_at = revoked_at.as_str(); + SqliteWorkspaceStore::revoke_workspace_runtime_binding( + store, + workspace_id, + runtime_id, + revoked_at, + ) + }) + .await } async fn put_workspace_runtime_binding_key( @@ -4676,12 +5046,22 @@ impl ControlPlaneStore for SqliteWorkspaceStore { expected_binding_id: Option<&str>, actor_account_id: &str, ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { - SqliteWorkspaceStore::put_workspace_runtime_binding_key( - self, - record, - expected_binding_id, - actor_account_id, - ) + let store = self.clone(); + let expected_binding_id = expected_binding_id.map(str::to_owned); + let actor_account_id = actor_account_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let expected_binding_id = expected_binding_id.as_deref(); + let actor_account_id = actor_account_id.as_str(); + SqliteWorkspaceStore::put_workspace_runtime_binding_key( + store, + record, + expected_binding_id, + actor_account_id, + ) + }) + .await } async fn update_workspace_runtime_binding_metadata( @@ -4692,14 +5072,30 @@ impl ControlPlaneStore for SqliteWorkspaceStore { base_url: &str, updated_at: &str, ) -> Result { - SqliteWorkspaceStore::update_workspace_runtime_binding_metadata( - self, - workspace_id, - runtime_id, - display_name, - base_url, - updated_at, - ) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + let display_name = display_name.to_owned(); + let base_url = base_url.to_owned(); + let updated_at = updated_at.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let runtime_id = runtime_id.as_str(); + let display_name = display_name.as_str(); + let base_url = base_url.as_str(); + let updated_at = updated_at.as_str(); + SqliteWorkspaceStore::update_workspace_runtime_binding_metadata( + store, + workspace_id, + runtime_id, + display_name, + base_url, + updated_at, + ) + }) + .await } async fn revoke_workspace_runtime_binding_key( @@ -4710,14 +5106,30 @@ impl ControlPlaneStore for SqliteWorkspaceStore { actor_account_id: &str, revoked_at: &str, ) -> Result<(WorkspaceRuntimeBindingMutation, WorkspaceRuntimeBinding)> { - SqliteWorkspaceStore::revoke_workspace_runtime_binding_key( - self, - workspace_id, - runtime_id, - expected_binding_id, - actor_account_id, - revoked_at, - ) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + let expected_binding_id = expected_binding_id.to_owned(); + let actor_account_id = actor_account_id.to_owned(); + let revoked_at = revoked_at.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let runtime_id = runtime_id.as_str(); + let expected_binding_id = expected_binding_id.as_str(); + let actor_account_id = actor_account_id.as_str(); + let revoked_at = revoked_at.as_str(); + SqliteWorkspaceStore::revoke_workspace_runtime_binding_key( + store, + workspace_id, + runtime_id, + expected_binding_id, + actor_account_id, + revoked_at, + ) + }) + .await } async fn list_workspace_runtime_binding_audit( @@ -4726,12 +5138,22 @@ impl ControlPlaneStore for SqliteWorkspaceStore { runtime_id: &str, limit: usize, ) -> Result> { - SqliteWorkspaceStore::list_workspace_runtime_binding_audit( - self, - workspace_id, - runtime_id, - limit, - ) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let runtime_id = runtime_id.as_str(); + SqliteWorkspaceStore::list_workspace_runtime_binding_audit( + store, + workspace_id, + runtime_id, + limit, + ) + }) + .await } async fn consume_worker_mutation_source_jti( @@ -4743,21 +5165,35 @@ impl ControlPlaneStore for SqliteWorkspaceStore { now_seconds: u64, consumed_at: &str, ) -> Result { - self.with_conn(|conn| { - let transaction = conn.unchecked_transaction()?; - transaction.execute( - "DELETE FROM worker_mutation_source_proof_jtis WHERE expires_at < ?1", - params![now_seconds], - )?; - let inserted = transaction.execute( - r#"INSERT OR IGNORE INTO worker_mutation_source_proof_jtis ( - workspace_id, runtime_id, jti, expires_at, consumed_at - ) VALUES (?1, ?2, ?3, ?4, ?5)"#, - params![workspace_id, runtime_id, jti, expires_at, consumed_at], - )?; - transaction.commit()?; - Ok(inserted == 1) - }) + let store = self.clone(); + let workspace_id = workspace_id.to_owned(); + let runtime_id = runtime_id.to_owned(); + let jti = jti.to_owned(); + let consumed_at = consumed_at.to_owned(); + self.db_executor + .run(move || { + let store = &store; + let workspace_id = workspace_id.as_str(); + let runtime_id = runtime_id.as_str(); + let jti = jti.as_str(); + let consumed_at = consumed_at.as_str(); + store.with_conn(|conn| { + let transaction = conn.unchecked_transaction()?; + transaction.execute( + "DELETE FROM worker_mutation_source_proof_jtis WHERE expires_at < ?1", + params![now_seconds], + )?; + let inserted = transaction.execute( + r#"INSERT OR IGNORE INTO worker_mutation_source_proof_jtis ( + workspace_id, runtime_id, jti, expires_at, consumed_at + ) VALUES (?1, ?2, ?3, ?4, ?5)"#, + params![workspace_id, runtime_id, jti, expires_at, consumed_at], + )?; + transaction.commit()?; + Ok(inserted == 1) + }) + }) + .await } fn plan_worker_removal( @@ -7501,10 +7937,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { let operation_id: Option = conn .query_row( - "SELECT operation_id FROM ticket_assignment_operations - WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 - AND claim_state='pending' AND action IN ('assign','reassign') - AND assignment_id IS NULL ORDER BY created_at, operation_id LIMIT 1", + "SELECT claim.operation_id FROM ticket_assignment_operations AS claim + JOIN typed_tickets AS ticket ON ticket.workspace_id=claim.workspace_id + AND ticket.ticket_id=claim.ticket_id + WHERE claim.workspace_id=?1 AND claim.runtime_id=?2 AND claim.worker_id=?3 + AND ticket.workflow_state='open' + AND claim.claim_state='pending' AND claim.action IN ('assign','reassign') + AND claim.assignment_id IS NULL ORDER BY claim.created_at, claim.operation_id LIMIT 1", params![workspace_id, worker.runtime_id, worker.worker_id], |row| row.get(0), ) @@ -7551,95 +7990,90 @@ impl ControlPlaneStore for SqliteWorkspaceStore { request_fingerprint: &str, created_at: &str, ) -> Result { - validate_non_empty("operation_id", operation_id)?; - validate_non_empty("request_fingerprint", request_fingerprint)?; + self.reserve_ticket_assignment_operation_with_fence( + workspace_id, + operation_id, + ticket_id, + runtime_id, + worker_id, + request_fingerprint, + created_at, + None, + ) + } + fn reserve_ticket_assignment_operation_for_orchestrator( + &self, + workspace_id: &str, + operation_id: &str, + ticket_id: &str, + runtime_id: &str, + worker_id: Option<&str>, + request_fingerprint: &str, + created_at: &str, + orchestrator_assignment_id: &str, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result { + self.reserve_ticket_assignment_operation_with_fence( + workspace_id, + operation_id, + ticket_id, + runtime_id, + worker_id, + request_fingerprint, + created_at, + Some((orchestrator_assignment_id, source)), + ) + } + fn validate_ticket_assignment_operation_fence( + &self, + workspace_id: &str, + operation_id: &str, + ) -> Result<()> { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - if let Some(existing) = read_assignment_operation(&tx, workspace_id, operation_id)? { - if existing.action == "assign" - && existing.ticket_id == ticket_id - && existing.runtime_id.as_deref() == Some(runtime_id) - && (worker_id.is_none() - || existing - .worker - .as_ref() - .map(|worker| worker.worker_id.as_str()) - == worker_id) - && existing.expected_assignment_id.is_none() - && existing.request_fingerprint.as_deref() == Some(request_fingerprint) - { - tx.commit()?; - return Ok(false); - } - return Err(Error::TicketAssignmentConflict(format!( - "assignment operation {operation_id} was already used with different input" - ))); - } - let worker = worker_id.map(|id| RuntimeWorkerRef::new(runtime_id, id)); - ensure_no_competing_ticket_claim( - &tx, - workspace_id, - ticket_id, - worker.as_ref(), - operation_id, - )?; - if let Some(worker) = &worker { - ensure_worker_assignment_available(&tx, workspace_id, worker)?; - let occupied: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments - WHERE workspace_id=?1 AND role='worker' AND runtime_id=?2 AND worker_id=?3)", - params![workspace_id, worker.runtime_id, worker.worker_id], - |row| row.get(0), - )?; - if occupied { - return Err(Error::TicketAssignmentConflict( - "Worker has unfinished Ticket work".into(), - )); - } - } - let state: String = tx.query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", - params![workspace_id, ticket_id], - |row| row.get(0), - )?; - if !matches!( - state.as_str(), - "planning" | "ready" | "queued" | "inprogress" - ) { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket `{ticket_id}` cannot claim work in state `{state}`" - ))); - } - let occupied: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments - WHERE workspace_id=?1 AND ticket_id=?2 AND role='worker')", - params![workspace_id, ticket_id], - |row| row.get(0), - )?; - if occupied { - return Err(Error::TicketAssignmentConflict( - "Ticket has unfinished Worker work".into(), - )); - } - tx.execute( - "INSERT INTO ticket_assignment_operations ( - workspace_id,operation_id,action,ticket_id,runtime_id,worker_id,assignment_id, - expected_assignment_id,created_at,request_fingerprint,claim_state) - VALUES(?1,?2,'assign',?3,?4,?5,NULL,NULL,?6,?7,'pending')", - params![ - workspace_id, - operation_id, - ticket_id, - runtime_id, - worker_id, - created_at, - request_fingerprint - ], - )?; + assignment_delivery::operation_guard(&tx, workspace_id, operation_id)?; tx.commit()?; - Ok(true) + Ok(()) }) } + fn list_pending_ticket_assignment_deliveries( + &self, + workspace_id: &str, + limit: usize, + ) -> Result> { + if !(1..=1000).contains(&limit) { + return Err(Error::InvalidInput( + "Assignment delivery scan limit must be 1..=1000".into(), + )); + } + self.with_conn(|conn| assignment_delivery::list_in_tx(conn, workspace_id, limit)) + } + fn reserve_ticket_assignment_delivery( + &self, + workspace_id: &str, + assignment_id: &str, + input_event_id: &str, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result { + assignment_delivery::reserve(self, workspace_id, assignment_id, input_event_id, source) + } + fn prepare_ticket_assignment_delivery_content( + &self, + workspace_id: &str, + delivery_id: &str, + content: &str, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result { + assignment_delivery::prepare_content(self, workspace_id, delivery_id, content, source) + } + fn ack_ticket_assignment_delivery( + &self, + workspace_id: &str, + delivery_id: &str, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result<()> { + assignment_delivery::ack(self, workspace_id, delivery_id, source) + } fn bind_ticket_assignment_operation_worker( &self, @@ -7649,6 +8083,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result<()> { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + assignment_delivery::operation_guard(&tx,workspace_id,operation_id)?; let receipt = read_assignment_operation(&tx, workspace_id, operation_id)? .ok_or_else(|| Error::TicketAssignmentConflict(format!("missing Ticket claim `{operation_id}`")))?; // Exact binding replay is safe even after assignment finalization. @@ -7660,6 +8095,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { || receipt.worker.as_ref().is_some_and(|w| w.worker_id != worker_id) { return Err(Error::TicketAssignmentConflict(format!("assignment operation {operation_id} cannot bind Worker {worker_id}"))); } + let open: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='open')", + params![workspace_id, receipt.ticket_id], |row| row.get(0), + )?; + if !open { return Err(Error::TicketAssignmentConflict("closed Ticket cannot bind pending work".into())); } let runtime_id = receipt.runtime_id.as_deref().ok_or_else(|| Error::TicketAssignmentConflict("pending Worker claim has no Runtime".into()))?; let worker = RuntimeWorkerRef::new(runtime_id, worker_id); ensure_no_competing_ticket_claim(&tx, workspace_id, &receipt.ticket_id, Some(&worker), operation_id)?; @@ -7703,23 +8143,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { transaction.commit()?; return Ok(()); // Pending/failed fingerprints are durable receipts. } - ensure_no_competing_ticket_claim(&transaction, workspace_id, &operation.ticket_id, operation.worker.as_ref(), operation_id)?; - transaction.execute( - "DELETE FROM ticket_assignment_operations WHERE workspace_id = ?1 AND operation_id = ?2", - params![workspace_id, operation_id], - )?; - if let Some(assignment_id) = operation.assignment_id { - transaction.execute( - "DELETE FROM ticket_worker_assignment_events WHERE workspace_id = ?1 AND ticket_id = ?2 AND assignment_id = ?3", - params![workspace_id, operation.ticket_id, assignment_id], - )?; - transaction.execute( - "DELETE FROM ticket_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2 AND assignment_id = ?3", - params![workspace_id, operation.ticket_id, assignment_id], - )?; - } - transaction.commit()?; - Ok(()) + Err(Error::TicketAssignmentConflict(format!("Ticket operation {operation_id} is committed; use a new explicit unassign operation instead of deleting immutable assignment evidence"))) }) } @@ -7786,6 +8210,99 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn page_ticket_assignments( + &self, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, + ) -> Result { + assignment_tracking::assignments_page( + self, + workspace_id, + ticket_id, + after_assignment_id, + limit, + ) + } + fn page_ticket_assignment_diagnostics( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_input_event_id: Option<&str>, + after_start_operation_id: Option<&str>, + limit: usize, + ) -> Result { + assignment_delivery::diagnostics_page( + self, + workspace_id, + ticket_id, + assignment_id, + after_input_event_id, + after_start_operation_id, + limit, + ) + } + fn answer_ticket_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + input: &ticket::TicketAssignmentAnswer, + source: &TicketOrchestratorAssignmentSourceContext, + actor: &str, + ) -> Result { + assignment_tracking::answer(self, workspace_id, ticket_id, input, source, actor) + } + fn update_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result { + assignment_tracking::update(self, workspace_id, ticket_id, update, source) + } + fn get_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result> { + assignment_tracking::get(self, workspace_id, ticket_id, assignment_id) + } + fn list_current_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result> { + assignment_tracking::current(self, workspace_id, ticket_id) + } + fn page_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_event_id: Option<&str>, + limit: usize, + ) -> Result { + assignment_tracking::history_page( + self, + workspace_id, + ticket_id, + assignment_id, + after_event_id, + limit, + ) + } + fn list_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result> { + assignment_tracking::history(self, workspace_id, ticket_id, assignment_id) + } fn list_current_ticket_role_assignments( &self, workspace_id: &str, @@ -7857,216 +8374,65 @@ impl ControlPlaneStore for SqliteWorkspaceStore { operation_id: &str, allow_reassign: bool, ) -> Result { - validate_ticket_assignment_role_principal(record.role, &record.principal)?; - let principal_json = serde_json::to_string(&record.principal).map_err(|error| { - Error::Store(format!("serialize Ticket assignment principal: {error}")) - })?; - let mut hasher = Sha256::new(); - hasher.update(b"ticket-role-assignment:set:v1\0"); - for value in [ - record.workspace_id.as_str(), - record.ticket_id.as_str(), - record.role.as_str(), - principal_json.as_str(), - record.assigned_by.as_str(), - expected_assignment_id.unwrap_or(""), - if allow_reassign { "reassign" } else { "assign" }, - ] { - hasher.update(value.as_bytes()); - hasher.update([0]); - } - let request_fingerprint = hasher - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect::(); - let (principal_id, runtime_id, worker_id) = match &record.principal { - TicketAssignmentPrincipal::User { account_id } => { - (Some(account_id.as_str()), None, None) - } - TicketAssignmentPrincipal::Worker { - runtime_id, - worker_id, - } => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())), - TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => { - (Some(agent_key.as_str()), None, None) - } - }; + self.set_ticket_role_assignment_with_source( + record, + expected_assignment_id, + event_id, + operation_id, + allow_reassign, + None, + ) + } + + fn set_current_ticket_orchestrator_assignment( + &self, + record: &TicketRoleAssignmentRecord, + expected_assignment_id: Option<&str>, + event_id: &str, + operation_id: &str, + allow_reassign: bool, + source: &TicketOrchestratorAssignmentSourceContext, + ) -> Result { + self.set_ticket_role_assignment_with_source( + record, + expected_assignment_id, + event_id, + operation_id, + allow_reassign, + Some(source), + ) + } + fn clear_current_ticket_orchestrator_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + event_id: &str, + operation_id: &str, + actor: &str, + occurred_at: &str, + reason: Option<&str>, + source: &TicketOrchestratorAssignmentSourceContext, + ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - if let Some(worker) = record.principal.worker() { - ensure_worker_assignment_available(&tx, &record.workspace_id, &worker)?; - } - - let existing_operation: Option<(String, Option)> = tx - .query_row( - "SELECT request_fingerprint, assignment_id - FROM ticket_assignment_operations - WHERE workspace_id = ?1 AND operation_id = ?2", - params![record.workspace_id, operation_id], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .optional()?; - if let Some((persisted_fingerprint, assignment_id)) = existing_operation { - if persisted_fingerprint != request_fingerprint { - return Err(Error::TicketAssignmentConflict(format!( - "operation `{operation_id}` was already used for different Ticket assignment input" - ))); - } - let assignment_id = assignment_id.ok_or_else(|| { - Error::Store(format!( - "Ticket role assignment operation `{operation_id}` is missing assignment identity" - )) - })?; - let persisted = read_ticket_role_assignment_by_id(&tx, &record.workspace_id, &assignment_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict(format!( - "assignment `{assignment_id}` recorded by operation `{operation_id}` no longer exists" - )) - })?; - tx.commit()?; - return Ok(persisted); - } - - ensure_no_competing_ticket_claim(&tx, &record.workspace_id, &record.ticket_id, - record.principal.worker().as_ref(), operation_id)?; - let current_sql = ticket_role_assignment_select_sql( - "WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = ?3 \ - ORDER BY a.assigned_at, a.assignment_id", - ); - let mut current_stmt = tx.prepare(¤t_sql)?; - let current = current_stmt - .query_map( - params![record.workspace_id, record.ticket_id, record.role.as_str()], - read_ticket_role_assignment_record, - )? - .collect::>>()?; - drop(current_stmt); - - let replaced = if let Some(expected_assignment_id) = expected_assignment_id { - let Some(expected) = current - .iter() - .find(|assignment| assignment.assignment_id == expected_assignment_id) - else { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket `{}` role `{}` expected assignment `{expected_assignment_id}`, current is {:?}", - record.ticket_id, - record.role.as_str(), - current.first().map(|value| value.assignment_id.as_str()) - ))); - }; - if !allow_reassign { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket `{}` role `{}` is already assigned as `{}`", - record.ticket_id, - record.role.as_str(), - expected.assignment_id - ))); - } - Some(expected.clone()) - } else if record.role.is_singleton() && !current.is_empty() { - let active: bool = tx.query_row( - "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2)", - params![record.workspace_id, current[0].assignment_id], |row| row.get(0))?; - if active { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} already has unfinished {} work; use explicit reassign", - record.ticket_id, record.role.as_str()))); - } - Some(current[0].clone()) - } else { - None - }; - - tx.execute( - "INSERT INTO ticket_assignment_operations ( - workspace_id, operation_id, action, ticket_id, role, principal_kind, - principal_id, runtime_id, worker_id, assignment_id, - expected_assignment_id, created_at, request_fingerprint - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", - params![ - record.workspace_id, - operation_id, - if replaced.is_some() { "reassign" } else { "assign" }, - record.ticket_id, - record.role.as_str(), - record.principal.kind(), - principal_id, - runtime_id, - worker_id, - record.assignment_id, - expected_assignment_id, - record.assigned_at, - request_fingerprint, - ], + assignment_tracking::require_assignment_source(&tx, workspace_id, source)?; + let result = clear_current_ticket_role_assignment_with_source_in_tx( + &tx, + workspace_id, + ticket_id, + TicketAssignmentRole::Orchestrator, + assignment_id, + event_id, + operation_id, + actor, + occurred_at, + reason, + "ticket-orchestrator-assignment:clear:v2", + Some(source), )?; - tx.execute( - "INSERT INTO ticket_worker_assignments ( - workspace_id, ticket_id, assignment_id, role, principal_kind, - principal_id, runtime_id, worker_id, assigned_by, assigned_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - record.workspace_id, - record.ticket_id, - record.assignment_id, - record.role.as_str(), - record.principal.kind(), - principal_id, - runtime_id, - worker_id, - record.assigned_by, - record.assigned_at, - ], - )?; - if let Some(previous) = &replaced { - tx.execute( - "DELETE FROM ticket_current_worker_assignments - WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = ?3 AND assignment_id = ?4", - params![ - record.workspace_id, - record.ticket_id, - record.role.as_str(), - previous.assignment_id, - ], - )?; - } - tx.execute( - "INSERT INTO ticket_current_worker_assignments ( - workspace_id, ticket_id, role, assignment_id, principal_kind, - principal_id, runtime_id, worker_id, updated_at - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", - params![ - record.workspace_id, - record.ticket_id, - record.role.as_str(), - record.assignment_id, - record.principal.kind(), - principal_id, - runtime_id, - worker_id, - record.assigned_at, - ], - )?; - tx.execute( - "INSERT INTO ticket_worker_assignment_events ( - workspace_id, ticket_id, role, event_id, action, assignment_id, - previous_assignment_id, actor, created_at, operation_id - ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", - params![ - record.workspace_id, - record.ticket_id, - record.role.as_str(), - event_id, - if replaced.is_some() { "reassigned" } else { "assigned" }, - record.assignment_id, - replaced.as_ref().map(|value| value.assignment_id.as_str()), - record.assigned_by, - record.assigned_at, - operation_id, - ], - )?; - tx.commit()?; - Ok(record.clone()) + tx.commit()?; + Ok(result) }) } @@ -8105,6 +8471,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + assignment_delivery::operation_guard(&tx,&record.workspace_id,operation_id)?; if let Some(worker) = record.principal.worker() { ensure_worker_assignment_available(&tx, &record.workspace_id, &worker)?; } @@ -8155,34 +8522,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![record.workspace_id, record.ticket_id], |row| row.get(0), )?; - if !matches!(state.as_str(), "planning" | "ready" | "queued" | "inprogress") { + if state != "open" { return Err(Error::TicketAssignmentConflict(format!( - "manual Worker assignment requires planning, ready, queued, or inprogress Ticket; current state is `{state}`" + "manual Worker assignment requires an open Ticket; current state is `{state}`" ))); } - let unresolved_blockers: i64 = tx.query_row( - "SELECT COUNT(*) - FROM typed_ticket_relations AS relation - JOIN typed_tickets AS related - ON related.workspace_id = relation.workspace_id - AND related.ticket_id = CASE - WHEN relation.ticket_id = ?2 THEN relation.target - ELSE relation.ticket_id - END - WHERE relation.workspace_id = ?1 - AND ( - (relation.ticket_id = ?2 AND relation.kind = 'depends_on') - OR (relation.target = ?2 AND relation.kind = 'blocks') - ) - AND related.workflow_state NOT IN ('done', 'closed')", - params![record.workspace_id, record.ticket_id], - |row| row.get(0), - )?; - if unresolved_blockers != 0 { - return Err(Error::TicketAssignmentConflict( - "manual Worker assignment is blocked by unresolved Ticket relations".to_string(), - )); - } + // Relations remain diagnostics; they do not reserve work or block partial starts. let conflicting: i64 = tx.query_row( "SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2 @@ -8258,10 +8603,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "INSERT INTO typed_ticket_events ( workspace_id, ticket_id, event_index, kind, author, at, from_state, to_state, reason, state_field, heading, body - ) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5, ?6, 'inprogress', - 'manual Worker assignment accepted', 'state', 'State changed', '')", + ) VALUES (?1, ?2, ?3, 'assignment', ?4, ?5, NULL, NULL, + 'manual Worker assignment accepted', NULL, 'Worker assigned', '')", params![record.workspace_id, record.ticket_id, event_index, - record.assigned_by, record.assigned_at, state], + record.assigned_by, record.assigned_at], )?; for (key, value) in [ ("event_id", event_id), @@ -8277,17 +8622,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![record.workspace_id, record.ticket_id, event_index, key, value], )?; } - let updated = tx.execute( - "UPDATE typed_tickets SET workflow_state = 'inprogress', - workflow_state_explicit = 1, updated_at = ?3 - WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = ?4", - params![record.workspace_id, record.ticket_id, record.assigned_at, state], - )?; - if updated != 1 { - return Err(Error::TicketAssignmentConflict( - "Ticket state changed during manual Worker assignment".to_string(), - )); - } + // Assignment claims do not change the Ticket case state. tx.commit()?; Ok(record.clone()) }) @@ -8339,17 +8674,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let state: String = tx.query_row( - "SELECT workflow_state FROM typed_tickets - WHERE workspace_id = ?1 AND ticket_id = ?2", - params![workspace_id, ticket_id], - |row| row.get(0), - )?; - if !matches!(state.as_str(), "inprogress" | "ready") { - return Err(Error::TicketAssignmentConflict(format!( - "implementation cancellation requires an inprogress Ticket; current state is `{state}`" - ))); - } let cleared = clear_current_ticket_role_assignment_in_tx( &tx, workspace_id, @@ -8366,49 +8690,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { if !cleared { return Ok(false); } - if state == "inprogress" { - let event_index: i64 = tx.query_row( - "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events - WHERE workspace_id = ?1 AND ticket_id = ?2", - params![workspace_id, ticket_id], - |row| row.get(0), - )?; - tx.execute( - "INSERT INTO typed_ticket_events ( - workspace_id, ticket_id, event_index, kind, author, at, - from_state, to_state, reason, state_field, heading, body - ) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5, - 'inprogress', 'ready', ?6, 'state', - 'Implementation cancelled', '')", - params![workspace_id, ticket_id, event_index, actor, occurred_at, reason], - )?; - for (key, value) in [ - ("event_id", state_event_id), - ("assignment_id", assignment_id), - ("assignment_role", "worker"), - ("operation_id", operation_id), - ] { - tx.execute( - "INSERT INTO typed_ticket_event_attributes ( - workspace_id, ticket_id, event_index, key, value - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![workspace_id, ticket_id, event_index, key, value], - )?; - } - let updated = tx.execute( - "UPDATE typed_tickets SET workflow_state = 'ready', - workflow_state_explicit = 1, - queued_by = NULL, queued_at = NULL, updated_at = ?3 - WHERE workspace_id = ?1 AND ticket_id = ?2 - AND workflow_state = 'inprogress'", - params![workspace_id, ticket_id, occurred_at], - )?; - if updated != 1 { - return Err(Error::TicketAssignmentConflict( - "Ticket state changed during implementation cancellation".to_string(), - )); - } - } + // Ending work never reopens or rolls back a case judgment. + let _ = state_event_id; // Compatibility slot for the old authority caller. + tx.execute( + "UPDATE typed_tickets SET queued_by = NULL, queued_at = NULL + WHERE workspace_id = ?1 AND ticket_id = ?2", + params![workspace_id, ticket_id], + )?; tx.commit()?; Ok(true) }) @@ -8461,6 +8749,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + assignment_delivery::operation_guard(&tx,&record.workspace_id,operation_id)?; let removal_blocks_assignment: bool = tx.query_row( "SELECT EXISTS( SELECT 1 FROM worker_removal_operations @@ -11485,6 +11774,365 @@ fn ensure_worker_assignment_available( Ok(()) } +impl SqliteWorkspaceStore { + fn reserve_ticket_assignment_operation_with_fence( + &self, + workspace_id: &str, + operation_id: &str, + ticket_id: &str, + runtime_id: &str, + worker_id: Option<&str>, + request_fingerprint: &str, + created_at: &str, + fence: Option<(&str, &TicketAssignmentTrackingSourceContext)>, + ) -> Result { + validate_non_empty("operation_id", operation_id)?; + validate_non_empty("request_fingerprint", request_fingerprint)?; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + assignment_delivery::operation_guard(&tx,workspace_id,operation_id)?; + if fence.is_none() { + let fenced:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_assignment_operation_fences WHERE workspace_id=?1 AND operation_id=?2)",params![workspace_id,operation_id],|r|r.get(0))?; + if fenced {return Err(Error::TicketAssignmentConflict("Source-bound reservation requires authenticated Orchestrator context".into()));} + } + if let Some(existing) = read_assignment_operation(&tx, workspace_id, operation_id)? { + if existing.action == "assign" + && existing.ticket_id == ticket_id + && existing.runtime_id.as_deref() == Some(runtime_id) + && (worker_id.is_none() + || existing + .worker + .as_ref() + .map(|worker| worker.worker_id.as_str()) + == worker_id) + && existing.expected_assignment_id.is_none() + && existing.request_fingerprint.as_deref() == Some(request_fingerprint) + { + if let Some((assignment_id,source))=fence { + assignment_delivery::save_operation_fence(&tx,workspace_id,operation_id,ticket_id,assignment_id,source,true)?; + } + tx.commit()?; + return Ok(false); + } + return Err(Error::TicketAssignmentConflict(format!( + "assignment operation {operation_id} was already used with different input" + ))); + } + if let Some((assignment_id,source))=fence { + assignment_tracking::require_runtime_source(&tx,workspace_id,&source.runtime_id,&source.worker_id,&source.agent_key,&source.authentication_binding_id)?; + assignment_delivery::require_current(&tx,workspace_id,ticket_id,assignment_id)?; + } + let worker = worker_id.map(|id| RuntimeWorkerRef::new(runtime_id, id)); + ensure_no_competing_ticket_claim( + &tx, + workspace_id, + ticket_id, + worker.as_ref(), + operation_id, + )?; + if let Some(worker) = &worker { + ensure_worker_assignment_available(&tx, workspace_id, worker)?; + let occupied: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id=?1 AND role='worker' AND runtime_id=?2 AND worker_id=?3)", + params![workspace_id, worker.runtime_id, worker.worker_id], + |row| row.get(0), + )?; + if occupied { + return Err(Error::TicketAssignmentConflict( + "Worker has unfinished Ticket work".into(), + )); + } + } + let state: String = tx.query_row( + "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", + params![workspace_id, ticket_id], + |row| row.get(0), + )?; + if state != "open" { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket `{ticket_id}` cannot claim work in state `{state}`" + ))); + } + let occupied: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id=?1 AND ticket_id=?2 AND role='worker')", + params![workspace_id, ticket_id], + |row| row.get(0), + )?; + if occupied { + return Err(Error::TicketAssignmentConflict( + "Ticket has unfinished Worker work".into(), + )); + } + tx.execute( + "INSERT INTO ticket_assignment_operations ( + workspace_id,operation_id,action,ticket_id,runtime_id,worker_id,assignment_id, + expected_assignment_id,created_at,request_fingerprint,claim_state) + VALUES(?1,?2,'assign',?3,?4,?5,NULL,NULL,?6,?7,'pending')", + params![ + workspace_id, + operation_id, + ticket_id, + runtime_id, + worker_id, + created_at, + request_fingerprint + ], + )?; + if let Some((assignment_id,source))=fence { + assignment_delivery::save_operation_fence(&tx,workspace_id,operation_id,ticket_id,assignment_id,source,false)?; + } + tx.commit()?; + Ok(true) + }) + } + + fn set_ticket_role_assignment_with_source( + &self, + record: &TicketRoleAssignmentRecord, + expected_assignment_id: Option<&str>, + event_id: &str, + operation_id: &str, + allow_reassign: bool, + source: Option<&TicketOrchestratorAssignmentSourceContext>, + ) -> Result { + if source.is_some() && record.role != TicketAssignmentRole::Orchestrator { + return Err(Error::TicketAssignmentConflict( + "source-aware assignment requires the Orchestrator role".into(), + )); + } + let source_json = source + .map(serde_json::to_string) + .transpose() + .map_err(|error| Error::Store(error.to_string()))?; + validate_ticket_assignment_role_principal(record.role, &record.principal)?; + let principal_json = serde_json::to_string(&record.principal).map_err(|error| { + Error::Store(format!("serialize Ticket assignment principal: {error}")) + })?; + let mut hasher = Sha256::new(); + if let Some(source_json) = &source_json { + hasher.update(b"ticket-orchestrator-assignment:set:v2\0"); + hasher.update(source_json.as_bytes()); + hasher.update([0]); + } else { + hasher.update(b"ticket-role-assignment:set:v1\0"); + } + for value in [ + record.workspace_id.as_str(), + record.ticket_id.as_str(), + record.role.as_str(), + principal_json.as_str(), + record.assigned_by.as_str(), + expected_assignment_id.unwrap_or(""), + if allow_reassign { "reassign" } else { "assign" }, + ] { + hasher.update(value.as_bytes()); + hasher.update([0]); + } + let request_fingerprint = hasher + .finalize() + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::(); + let (principal_id, runtime_id, worker_id) = match &record.principal { + TicketAssignmentPrincipal::User { account_id } => { + (Some(account_id.as_str()), None, None) + } + TicketAssignmentPrincipal::Worker { + runtime_id, + worker_id, + } => (None, Some(runtime_id.as_str()), Some(worker_id.as_str())), + TicketAssignmentPrincipal::WorkspaceAgent { agent_key } => { + (Some(agent_key.as_str()), None, None) + } + }; + self.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + assignment_delivery::operation_guard(&tx,&record.workspace_id,operation_id)?; + if let Some(source)=source {assignment_tracking::require_assignment_source(&tx,&record.workspace_id,source)?;} + if let Some(worker) = record.principal.worker() { + ensure_worker_assignment_available(&tx, &record.workspace_id, &worker)?; + } + + let existing_operation: Option<(Option, Option)> = tx + .query_row( + "SELECT request_fingerprint, assignment_id + FROM ticket_assignment_operations + WHERE workspace_id = ?1 AND operation_id = ?2", + params![record.workspace_id, operation_id], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional()?; + if let Some((persisted_fingerprint, assignment_id)) = existing_operation { + if persisted_fingerprint.as_deref() != Some(request_fingerprint.as_str()) { + return Err(Error::TicketAssignmentConflict(format!( + "operation `{operation_id}` was already used for different Ticket assignment input" + ))); + } + let assignment_id = assignment_id.ok_or_else(|| { + Error::Store(format!( + "Ticket role assignment operation `{operation_id}` is missing assignment identity" + )) + })?; + let persisted = read_ticket_role_assignment_by_id(&tx, &record.workspace_id, &assignment_id)? + .ok_or_else(|| { + Error::TicketAssignmentConflict(format!( + "assignment `{assignment_id}` recorded by operation `{operation_id}` no longer exists" + )) + })?; + tx.commit()?; + return Ok(persisted); + } + + // Delegation has no Runtime/resource occupancy. It can invalidate + // an in-flight startup fence; existing actual Worker/claim resource + // exclusion is checked independently at the next work admission. + if record.role != TicketAssignmentRole::Orchestrator { + ensure_no_competing_ticket_claim(&tx, &record.workspace_id, &record.ticket_id, + record.principal.worker().as_ref(), operation_id)?; + } + let current_sql = ticket_role_assignment_select_sql( + "WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 AND current.role = ?3 \ + ORDER BY a.assigned_at, a.assignment_id", + ); + let mut current_stmt = tx.prepare(¤t_sql)?; + let current = current_stmt + .query_map( + params![record.workspace_id, record.ticket_id, record.role.as_str()], + read_ticket_role_assignment_record, + )? + .collect::>>()?; + drop(current_stmt); + + let replaced = if let Some(expected_assignment_id) = expected_assignment_id { + let Some(expected) = current + .iter() + .find(|assignment| assignment.assignment_id == expected_assignment_id) + else { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket `{}` role `{}` expected assignment `{expected_assignment_id}`, current is {:?}", + record.ticket_id, + record.role.as_str(), + current.first().map(|value| value.assignment_id.as_str()) + ))); + }; + if !allow_reassign { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket `{}` role `{}` is already assigned as `{}`", + record.ticket_id, + record.role.as_str(), + expected.assignment_id + ))); + } + Some(expected.clone()) + } else if record.role.is_singleton() && !current.is_empty() { + let active: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND assignment_id=?2)", + params![record.workspace_id, current[0].assignment_id], |row| row.get(0))?; + if active && record.role != TicketAssignmentRole::Orchestrator { + return Err(Error::TicketAssignmentConflict(format!( + "Ticket {} already has unfinished {} work; use explicit reassign", + record.ticket_id, record.role.as_str()))); + } + Some(current[0].clone()) + } else { + None + }; + + tx.execute( + "INSERT INTO ticket_assignment_operations ( + workspace_id, operation_id, action, ticket_id, role, principal_kind, + principal_id, runtime_id, worker_id, assignment_id, + expected_assignment_id, created_at, request_fingerprint + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)", + params![ + record.workspace_id, + operation_id, + if replaced.is_some() { "reassign" } else { "assign" }, + record.ticket_id, + record.role.as_str(), + record.principal.kind(), + principal_id, + runtime_id, + worker_id, + record.assignment_id, + expected_assignment_id, + record.assigned_at, + request_fingerprint, + ], + )?; + tx.execute( + "INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, role, principal_kind, + principal_id, runtime_id, worker_id, assigned_by, assigned_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + record.workspace_id, + record.ticket_id, + record.assignment_id, + record.role.as_str(), + record.principal.kind(), + principal_id, + runtime_id, + worker_id, + record.assigned_by, + record.assigned_at, + ], + )?; + if let Some(previous) = &replaced { + tx.execute( + "DELETE FROM ticket_current_worker_assignments + WHERE workspace_id = ?1 AND ticket_id = ?2 AND role = ?3 AND assignment_id = ?4", + params![ + record.workspace_id, + record.ticket_id, + record.role.as_str(), + previous.assignment_id, + ], + )?; + } + tx.execute( + "INSERT INTO ticket_current_worker_assignments ( + workspace_id, ticket_id, role, assignment_id, principal_kind, + principal_id, runtime_id, worker_id, updated_at + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)", + params![ + record.workspace_id, + record.ticket_id, + record.role.as_str(), + record.assignment_id, + record.principal.kind(), + principal_id, + runtime_id, + worker_id, + record.assigned_at, + ], + )?; + tx.execute( + "INSERT INTO ticket_worker_assignment_events ( + workspace_id, ticket_id, role, event_id, action, assignment_id, + previous_assignment_id, actor, created_at, operation_id + ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)", + params![ + record.workspace_id, + record.ticket_id, + record.role.as_str(), + event_id, + if replaced.is_some() { "reassigned" } else { "assigned" }, + record.assignment_id, + replaced.as_ref().map(|value| value.assignment_id.as_str()), + record.assigned_by, + record.assigned_at, + operation_id, + ], + )?; + tx.commit()?; + Ok(record.clone()) + }) + } +} + fn read_ticket_role_assignment_record( row: &rusqlite::Row<'_>, ) -> rusqlite::Result { @@ -11602,22 +12250,53 @@ fn clear_current_ticket_role_assignment_in_tx( occurred_at: &str, reason: Option<&str>, fingerprint_domain: &str, +) -> Result { + clear_current_ticket_role_assignment_with_source_in_tx( + tx, + workspace_id, + ticket_id, + role, + assignment_id, + event_id, + operation_id, + actor, + occurred_at, + reason, + fingerprint_domain, + None, + ) +} + +#[allow(clippy::too_many_arguments)] +fn clear_current_ticket_role_assignment_with_source_in_tx( + tx: &rusqlite::Transaction<'_>, + workspace_id: &str, + ticket_id: &str, + role: TicketAssignmentRole, + assignment_id: &str, + event_id: &str, + operation_id: &str, + actor: &str, + occurred_at: &str, + reason: Option<&str>, + fingerprint_domain: &str, + source: Option<&TicketOrchestratorAssignmentSourceContext>, ) -> Result { let current = read_ticket_role_assignment_by_id(tx, workspace_id, assignment_id)?; let Some(current) = current.filter(|value| value.ticket_id == ticket_id && value.role == role) else { return Ok(false); }; - ensure_no_competing_ticket_claim( - tx, - workspace_id, - ticket_id, - current.principal.worker().as_ref(), - operation_id, - )?; let principal_json = serde_json::to_string(¤t.principal) .map_err(|error| Error::Store(format!("serialize Ticket assignment principal: {error}")))?; let mut hasher = Sha256::new(); + if let Some(source) = source { + hasher.update( + serde_json::to_vec(&(source, reason)) + .map_err(|error| Error::Store(error.to_string()))?, + ); + hasher.update([0]); + } for value in [ fingerprint_domain, workspace_id, @@ -11646,6 +12325,28 @@ fn clear_current_ticket_role_assignment_in_tx( (Some(agent_key.as_str()), None, None) } }; + let persisted:Option>=tx.query_row("SELECT request_fingerprint FROM ticket_assignment_operations WHERE workspace_id=?1 AND operation_id=?2",params![workspace_id,operation_id],|row|row.get(0)).optional()?; + if let Some(persisted) = persisted { + if persisted.as_deref() != Some(fingerprint.as_str()) { + return Err(Error::TicketAssignmentConflict(format!( + "operation {operation_id} was already used for different assignment input or authenticated source" + ))); + } + return Ok(true); + } + if role != TicketAssignmentRole::Orchestrator { + ensure_no_competing_ticket_claim( + tx, + workspace_id, + ticket_id, + current.principal.worker().as_ref(), + operation_id, + )?; + } + let is_current:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role=?3 AND assignment_id=?4)",params![workspace_id,ticket_id,role.as_str(),assignment_id],|row|row.get(0))?; + if !is_current { + return Ok(false); + } let inserted = tx.execute( "INSERT OR IGNORE INTO ticket_assignment_operations ( workspace_id, operation_id, action, ticket_id, role, principal_kind, @@ -13134,7 +13835,9 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re TICKET_SCHEMA_BASELINE_NAME ], )?; - ticket::verify_sqlite_ticket_schema(db).map_err(|error| { + // Verify the retained six-state v7 boundary here; the shared domain + // migrator installs classification and cases only after Workspace auth90. + ticket::verify_pre_classification_ticket_schema(db).map_err(|error| { Error::Store(format!( "schema-66 Ticket schema verification failed: {error}" )) @@ -13201,7 +13904,113 @@ fn read_workspace_config_grant( }) } +fn install_ticket_case_assignment_work_schema(conn: &Connection) -> Result<()> { + let (_, sql) = include_str!("latest_schema.sql") + .split_once("-- Ticket case assignment work constraints (schema 91).") + .ok_or_else(|| Error::Store("case assignment schema resource is missing".into()))?; + conn.execute_batch(sql)?; + Ok(()) +} + +fn migrate_ticket_cases_v90_to_v91(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 90 { + return Err(Error::Store( + "Ticket case migration requires schema 90".into(), + )); + } + // The shared Ticket migration owns the six-state snapshot, unknown legacy + // closure reasons and immutable case history. It opens its own transaction. + // Leave the Workspace marker at 90 until our composed constraints commit; + // a failed second phase is resumable through the idempotent domain migrator. + ticket::migrate_sqlite_ticket_schema(conn) + .map_err(|error| Error::Store(format!("Ticket case migration failed: {error}")))?; + let invalid: i64 = conn.query_row( + "SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open', 'closed')", + [], + |row| row.get(0), + )?; + if invalid != 0 { + return Err(Error::Store( + "shared Ticket migration did not install the open/closed case contract".into(), + )); + } + let tx = conn.unchecked_transaction()?; + install_ticket_case_assignment_work_schema(&tx)?; + tx.execute( + "INSERT OR IGNORE INTO ticket_assignment_work_releases + SELECT current.workspace_id, current.assignment_id, current.updated_at + FROM ticket_current_worker_assignments AS current + JOIN typed_tickets AS ticket ON ticket.workspace_id = current.workspace_id + AND ticket.ticket_id = current.ticket_id + WHERE ticket.workflow_state = 'closed'", + [], + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version, name) VALUES (91, 'Ticket open/closed cases and retained routing evidence')", [], + )?; + verify_workspace_resource_constraints(&tx)?; + let violations: i64 = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + if violations != 0 { + return Err(Error::Store(format!( + "Ticket case migration left {violations} foreign-key violations" + ))); + } + tx.commit()?; + Ok(()) +} + +fn migrate_ticket_tracking_v91_to_v92(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 91 { + return Err(Error::Store( + "Ticket tracking migration requires schema 91".into(), + )); + } + let tx = conn.unchecked_transaction()?; + ticket::migrate_sqlite_ticket_schema_in_transaction(&tx) + .map_err(|error| Error::Store(error.to_string()))?; + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (92, 'Ticket-owned assignment tracking')", [])?; + verify_workspace_resource_constraints(&tx)?; + tx.commit()?; + Ok(()) +} + +fn migrate_assignment_delivery_v92_to_v93(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 92 { + return Err(Error::Store( + "Assignment delivery migration requires schema 92".into(), + )); + } + let tx = conn.unchecked_transaction()?; + assignment_delivery::install(&tx)?; + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES(93,'Durable assignment deliveries and source-bound startup fences')",[])?; + tx.commit()?; + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { + create_workspace_schema_structure(conn)?; + assignment_delivery::install(conn)?; + ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { + Error::Store(format!( + "fresh Workspace Ticket schema verification failed: {error}" + )) + }) +} + +// Workspace 92 changes only the Ticket-owned assignment component. Retained +// Workspace-91 fixtures compose this host structure with the genuine Ticket-10 +// schema, not a current database with lowered migration markers. +fn create_workspace_schema_structure(conn: &Connection) -> Result<()> { + // The composed Workspace parent FK rebuild must not discard domain-owned + // case guards. Retain their SQL from the shared fresh Ticket schema. + let ticket_parent_triggers = conn + .prepare("SELECT sql FROM sqlite_schema WHERE type = 'trigger' AND tbl_name = 'typed_tickets' ORDER BY name")? + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + conn.execute_batch("CREATE TABLE worker_restore_intents ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, domain_key TEXT NOT NULL, request_id TEXT NOT NULL, expected_token TEXT NOT NULL, @@ -13212,7 +14021,10 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { CREATE UNIQUE INDEX worker_restore_intent_pending_domain ON worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key) WHERE settled=0;")?; conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_targets;")?; - conn.execute_batch(include_str!("latest_schema.sql"))?; + conn.execute_batch(&include_str!("latest_schema.sql").replace( + "-- Ticket-owned assignment schema is composed by store.rs.", + ticket::TICKET_ASSIGNMENT_SCHEMA_SQL, + ))?; conn.execute_batch( r#" ALTER TABLE typed_tickets RENAME TO typed_tickets_legacy_v66; @@ -13278,12 +14090,10 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { )); "#, )?; - conn.execute_batch(include_str!("assignment_work.sql"))?; - ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { - Error::Store(format!( - "fresh Workspace Ticket schema verification failed: {error}" - )) - })?; + install_ticket_case_assignment_work_schema(conn)?; + for sql in ticket_parent_triggers { + conn.execute_batch(&sql)?; + } Ok(()) } @@ -13337,6 +14147,76 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { } } } + if current_schema_version(conn)? >= 91 { + for (name, predicate) in [ + ( + "ticket_active_worker_assignments", + "ticket.workflow_state = 'open'", + ), + ( + "ticket_terminal_releases_work", + "NEW.workflow_state = 'closed'", + ), + ( + "ticket_terminal_assignment_releases_work_insert", + "workflow_state = 'closed'", + ), + ( + "ticket_terminal_assignment_releases_work_update", + "workflow_state = 'closed'", + ), + ( + "ticket_active_worker_role_insert", + "workflow_state = 'open'", + ), + ( + "ticket_active_worker_role_update", + "workflow_state = 'open'", + ), + ] { + let sql: String = conn.query_row( + "SELECT sql FROM sqlite_schema WHERE name = ?1", + [name], + |row| row.get(0), + )?; + if !sql.contains(predicate) || sql.contains("'done'") { + return Err(Error::Store(format!( + "Ticket case constraint {name} does not match schema 91" + ))); + } + } + } + if current_schema_version(conn)? >= 93 { + for (kind, name) in [ + ("table", "ticket_assignment_delivery_origins"), + ("table", "ticket_assignment_deliveries"), + ("table", "ticket_assignment_delivery_requests"), + ("table", "ticket_assignment_delivery_acknowledgements"), + ("table", "ticket_assignment_operation_fences"), + ("trigger", "ticket_assignment_delivery_origin_insert"), + ("trigger", "ticket_assignment_delivery_origin_no_update"), + ("trigger", "ticket_assignment_delivery_origin_no_delete"), + ("trigger", "ticket_assignment_delivery_payload_no_update"), + ("trigger", "ticket_assignment_delivery_payload_no_delete"), + ("trigger", "ticket_assignment_delivery_request_no_update"), + ("trigger", "ticket_assignment_delivery_request_no_delete"), + ("trigger", "ticket_assignment_delivery_ack_no_update"), + ("trigger", "ticket_assignment_delivery_ack_no_delete"), + ("trigger", "ticket_assignment_operation_fence_no_update"), + ("trigger", "ticket_assignment_operation_fence_no_delete"), + ] { + let present: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type=?1 AND name=?2)", + params![kind, name], + |row| row.get(0), + )?; + if !present { + return Err(Error::Store(format!( + "Assignment delivery constraint {name} is missing" + ))); + } + } + } Ok(()) } @@ -14089,6 +14969,8 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result> { #[cfg(test)] mod tests { + include!("assignment_tracking_tests.rs"); + include!("assignment_delivery_tests.rs"); include!("store_workspace_config_tests.rs"); fn drive_grant_fixture(store: &SqliteWorkspaceStore) -> server_api::DriveGrantResponse { @@ -14118,7 +15000,7 @@ mod tests { fn drive_grants_migrate_and_preserve_audit_through_revoke_worker_delete_and_restart() { let t = tempfile::tempdir().unwrap(); let path = t.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); let legacy = Connection::open_in_memory().unwrap(); configure_sqlite(&legacy).unwrap(); legacy.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY); @@ -14208,7 +15090,7 @@ mod tests { Some(other) ); drop(store); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); assert_eq!( store .get_workspace_drive_grant("drive-ws", &first.grant_id) @@ -14243,7 +15125,7 @@ mod tests { } #[test] fn drive_grants_baseline_constraints_and_worker_lifetime_fail_closed() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); let proposed = drive_grant_fixture(&store); let first = store.create_workspace_drive_grant(&proposed).unwrap(); store.with_conn(|c| { @@ -14279,7 +15161,7 @@ mod tests { fn schema_v74_migrates_historical_worker_projection_rows_without_counter_state() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("workspace.db"); - drop(SqliteWorkspaceStore::open(&path).unwrap()); + drop(SqliteWorkspaceStore::open_for_test(&path).unwrap()); let conn = Connection::open(&path).unwrap(); configure_sqlite(&conn).unwrap(); conn.execute_batch( @@ -14518,6 +15400,9 @@ mod tests { } fn downgrade_schema_66_ticket_and_workdir_authority(conn: &Connection) -> Result<()> { + // A retained pre-target fixture must not carry today's classification + // tables while resetting the component migration marker to v6. + conn.execute_batch("DROP TABLE IF EXISTS typed_ticket_tags; DROP TABLE IF EXISTS workspace_tag_operations; DROP TABLE IF EXISTS workspace_tags;")?; conn.execute_batch("DROP TABLE IF EXISTS worker_restore_intents;")?; conn.execute_batch( "DROP TABLE backend_job_deliveries; @@ -14601,7 +15486,7 @@ mod tests { #[tokio::test] async fn external_workdir_grant_persists_typed_source_and_fences_generations() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store .upsert_account(&AccountRecord { account_id: "owner".to_string(), @@ -14973,7 +15858,7 @@ mod tests { fn runtime_removal_test_store() -> (tempfile::TempDir, SqliteWorkspaceStore) { let temp = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(temp.path().join("server.db")).unwrap(); store .with_conn(|conn| { conn.execute_batch( @@ -15357,7 +16242,7 @@ mod tests { let path = temp.path().join("server.db"); prepare_retained_schema(&path, 59); - let migrated = SqliteWorkspaceStore::open(&path).unwrap(); + let migrated = SqliteWorkspaceStore::open_for_test(&path).unwrap(); migrated .with_conn(|conn| { let table_count: i64 = conn.query_row( @@ -15393,7 +16278,7 @@ mod tests { let path = temp.path().join("server.db"); prepare_retained_schema(&path, 60); - drop(SqliteWorkspaceStore::open(&path).unwrap()); + drop(SqliteWorkspaceStore::open_for_test(&path).unwrap()); let conn = Connection::open(&path).unwrap(); assert_eq!( current_schema_version(&conn).unwrap(), @@ -15423,7 +16308,7 @@ mod tests { #[test] fn schema_v59_runtime_removal_migration_rolls_back_partial_ddl_and_marker() { let temp = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(temp.path().join("server.db")).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(temp.path().join("server.db")).unwrap(); store .with_conn(|conn| { let trigger_names = { @@ -15557,7 +16442,7 @@ mod tests { migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(&conn).unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 66); - ticket::verify_sqlite_ticket_schema(&conn).unwrap(); + ticket::verify_pre_classification_ticket_schema(&conn).unwrap(); assert!(!column_exists(&conn, "typed_tickets", "repository_id").unwrap()); assert!(!column_exists(&conn, "typed_tickets", "ref_selector").unwrap()); assert_eq!( @@ -15687,7 +16572,7 @@ mod tests { fn fresh_database_uses_only_the_canonical_workspace_baseline() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .with_conn(|conn| { let migrations = conn @@ -15835,7 +16720,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .collect::>(), (83..=LATEST_SCHEMA_VERSION).collect::>() ); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); let assignment = store .get_active_ticket_worker_assignment("workspace-a", "ticket") .unwrap() @@ -15868,6 +16753,19 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx // Legacy fixtures compose frozen counter-bearing SQL, never current-schema downgrades. fn create_frozen_schema_v85(conn: &Connection) -> Result<()> { + // Compose the frozen Ticket-7 baseline, not current Ticket case tables. + conn.execute_batch( + "DROP TABLE IF EXISTS typed_ticket_tags; + DROP TABLE IF EXISTS workspace_tag_operations; + DROP TABLE IF EXISTS workspace_tags; + DROP TABLE IF EXISTS typed_ticket_case_metadata; + DROP TABLE IF EXISTS typed_ticket_case_history; + DROP TABLE IF EXISTS typed_ticket_legacy_workflow_snapshot; + DELETE FROM ticket_schema_migrations; + INSERT INTO ticket_schema_migrations(version, name, applied_at) + VALUES(7, 'ticket schema baseline', 'fixture');", + )?; + conn.execute_batch("CREATE TABLE worker_restore_intents ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, domain_key TEXT NOT NULL, request_id TEXT NOT NULL, expected_token TEXT NOT NULL, @@ -15945,15 +16843,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx "#, )?; conn.execute_batch(include_str!("assignment_work.sql"))?; - ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { - Error::Store(format!( - "fresh Workspace Ticket schema verification failed: {error}" - )) - })?; + // Current composed Ticket verification follows the real retained chain. Ok(()) } include!("schema_equivalence_tests.rs"); + include!("ticket_tag_upgrade_tests.rs"); include!("migration_entrypoint_tests.rs"); include!("legacy_evidence_column_tests.rs"); @@ -15972,7 +16867,11 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx fn prepare_schema_v52(path: &Path) { let conn = Connection::open(path).unwrap(); configure_sqlite(&conn).unwrap(); - ticket::migrate_sqlite_ticket_schema(&conn).unwrap(); + // Retained fixtures start from genuine pre-tracking Ticket DDL; current + // component 11 would introduce assignment objects before frozen host 85. + conn.execute_batch(include_str!("../../ticket/src/latest_schema.sql")) + .unwrap(); + conn.execute_batch("CREATE TABLE ticket_schema_migrations(version INTEGER PRIMARY KEY,name TEXT NOT NULL,applied_at TEXT NOT NULL); INSERT INTO ticket_schema_migrations VALUES(10,'ticket schema baseline','fixture');").unwrap(); merge_request::migrate(&conn).unwrap(); conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;") .unwrap(); @@ -16276,6 +17175,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx version: 90, name: "Runtime binding connection configuration and historical verification archive".to_string() }, + WorkspaceSchemaMigrationStep { + version: 91, + name: "Ticket open/closed cases and retained routing evidence".to_string() + }, + WorkspaceSchemaMigrationStep {version:92, name:"Ticket-owned assignment tracking".into()}, + WorkspaceSchemaMigrationStep {version:93, name:"Durable assignment deliveries and source-bound startup fences".into()}, ] ); @@ -16286,7 +17191,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let applied = SqliteWorkspaceStore::migrate_database(&path).unwrap(); assert_eq!(applied, plan); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .with_conn(|conn| { assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); @@ -16376,6 +17281,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (88, "Runtime binding identities and retention content contracts".to_string()), (89, "descriptive legacy evidence columns".to_string()), (90, "Runtime binding connection configuration and historical verification archive".to_string()), +(91, "Ticket open/closed cases and retained routing evidence".to_string()), +(92, "Ticket-owned assignment tracking".to_string()), +(93, "Durable assignment deliveries and source-bound startup fences".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -16832,7 +17740,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx "migrated databases must reject incomplete singleton leases" ); drop(conn); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); let legacy = RuntimeWorkerRef::new("legacy-runtime", "legacy-orchestrator"); let lease = store .require_current_worker_singleton_owner("workspace-a", &legacy) @@ -17020,7 +17928,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .unwrap(); drop(conn); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .with_conn(|conn| { assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); @@ -17121,7 +18029,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .unwrap(); drop(conn); - let error = SqliteWorkspaceStore::open(&path) + let error = SqliteWorkspaceStore::open_for_test(&path) .err() .expect("migration must fail") .to_string(); @@ -17146,7 +18054,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx fn runtime_binding_identity_and_trust_uniqueness_are_workspace_scoped() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .with_conn(|conn| { conn.execute_batch( @@ -17248,7 +18156,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); drop(store); - let reopened = SqliteWorkspaceStore::open(&path).unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); assert!( reopened .list_workspace_runtime_bindings("workspace-a", false) @@ -17298,7 +18206,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx fn workspace_runtime_connection_persists_without_authentication_evidence() { let temp = tempfile::tempdir().unwrap(); let path = temp.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .with_conn(|conn| { conn.execute_batch( @@ -17343,7 +18251,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .unwrap(); assert!(store.workspace_runtime_binding_matches(&persisted).unwrap()); drop(store); - let reopened = SqliteWorkspaceStore::open(&path).unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); assert_eq!( reopened .get_workspace_runtime_binding("workspace-a", "runtime-a") @@ -17597,7 +18505,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[test] fn runtime_binding_key_mutations_are_identity_guarded_idempotent_and_audited() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store .with_conn(|conn| { conn.execute_batch( @@ -17735,7 +18643,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[test] fn embedded_runtime_binding_can_explicitly_rotate_restart_identity() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store .with_conn(|conn| { conn.execute_batch( @@ -17835,7 +18743,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx conn.execute_batch("DROP TABLE typed_ticket_artifacts") .unwrap(); - let result = SqliteWorkspaceStore::from_connection(conn); + let result = SqliteWorkspaceStore::from_connection_for_test(conn); let error = match result { Ok(_) => panic!("schema drift unexpectedly passed startup verification"), Err(error) => error, @@ -17847,7 +18755,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx 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(); + let store = SqliteWorkspaceStore::open_for_test(&database_path).unwrap(); for workspace_id in ["workspace-a", "workspace-b"] { store .upsert_workspace(&WorkspaceRecord { @@ -17901,14 +18809,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); drop(backend); - SqliteWorkspaceStore::open(&database_path).unwrap(); + SqliteWorkspaceStore::open_for_test(&database_path).unwrap(); } #[tokio::test] async fn migrates_sqlite_and_preserves_workspace_record() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("control-plane.sqlite"); - let store = SqliteWorkspaceStore::open(&db).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&db).unwrap(); assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION); assert!( @@ -17926,7 +18834,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx }; store.upsert_workspace(&record).await.unwrap(); - let reopened = SqliteWorkspaceStore::open(&db).unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(&db).unwrap(); assert_eq!( reopened.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION @@ -17941,7 +18849,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx async fn workspace_display_name_update_is_timestamp_guarded_and_preserves_identity() { let dir = tempfile::tempdir().unwrap(); let database_path = dir.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&database_path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&database_path).unwrap(); let record = WorkspaceRecord { workspace_id: "workspace-a".to_string(), owner_account_id: "owner-account".to_string(), @@ -17980,7 +18888,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx updated ); drop(store); - let reopened = SqliteWorkspaceStore::open(database_path).unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(database_path).unwrap(); assert_eq!( reopened .get_workspace(&record.workspace_id) @@ -17994,7 +18902,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn objective_creation_allocates_and_resolves_workspace_resource_key() { let dir = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-a".into(), @@ -18040,7 +18948,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn worker_create_reservation_allocates_uuid_before_runtime_and_replays_exact_input() { let dir = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-a".to_string(), @@ -18344,7 +19252,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx async fn worker_singleton_restart_retry_replays_exact_workdir_intent_and_rejects_changes() { let dir = tempfile::tempdir().unwrap(); let database = dir.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&database).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&database).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-a".to_string(), @@ -18404,7 +19312,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .unwrap(); drop(store); - let reopened = SqliteWorkspaceStore::open(&database).unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(&database).unwrap(); let retried = reopened .reserve_worker_create( "workspace-a", @@ -18448,7 +19356,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx async fn worker_singleton_ownership_is_workspace_scoped_durable_and_fences_old_generations() { let dir = tempfile::tempdir().unwrap(); let database = dir.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&database).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&database).unwrap(); for workspace_id in ["workspace-a", "workspace-b"] { store .upsert_workspace(&WorkspaceRecord { @@ -18561,7 +19469,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); drop(store); - let reopened = SqliteWorkspaceStore::open(database).unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(database).unwrap(); assert_eq!( reopened .current_worker_singleton_owner("workspace-a", key) @@ -18574,7 +19482,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx async fn concurrent_worker_singleton_reservations_have_one_winner() { let dir = tempfile::tempdir().unwrap(); let database = dir.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&database).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&database).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-a".to_string(), @@ -18594,7 +19502,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let database = database.clone(); let barrier = barrier.clone(); std::thread::spawn(move || { - let store = SqliteWorkspaceStore::open(database).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(database).unwrap(); let memory = store.get_workspace_memory_settings("workspace-a").unwrap(); barrier.wait(); store.reserve_worker_create( @@ -18614,7 +19522,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .collect::>(); assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1); assert_eq!(results.iter().filter(|result| result.is_err()).count(), 1); - let store = SqliteWorkspaceStore::open(database).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(database).unwrap(); let owner = store .current_worker_singleton_owner("workspace-a", "shared-key") .unwrap() @@ -18630,7 +19538,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn workspace_flow_sources_pin_content_and_builtins_stay_resources() { let dir = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-a".to_string(), @@ -18723,7 +19631,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx "returning to earlier content must reuse the immutable content" ); drop(store); - let reopened = SqliteWorkspaceStore::open(dir.path().join("server.db")).unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(dir.path().join("server.db")).unwrap(); assert_eq!( reopened .get_flow_source_content( @@ -18738,6 +19646,681 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); } + // SQLite boundary: genuine retained Workspace-89 or -90 / Ticket-7, + // including the real authentication cutover, not a lowered version marker. + fn legacy_case_workspace(version: i64) -> (tempfile::TempDir, std::path::PathBuf) { + assert!(matches!(version, 89 | 90)); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cases.db"); + prepare_retained_schema(&path, 89); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) + VALUES('case-owner','user','case-owner','Owner','1','1'); + INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) + VALUES('case-workspace','case-owner','Cases','active','1','1'); + INSERT INTO repositories(workspace_id,repository_id,repository_key,kind,provider,uri,default_ref,created_at,updated_at,source_kind,source_uri,source_fingerprint,observed_status) + VALUES('case-workspace','case-repo','main','git','git','/fixture','develop','1','1','local_path','/fixture','sha256:fixture','unverified');").unwrap(); + conn.execute_batch(r#" + INSERT INTO workspace_signing_identities(workspace_id,key_id,algorithm,public_key,public_key_fingerprint,private_material_ref,state,created_at,provisioned_at,updated_at) + VALUES('case-workspace','case-key','ed25519','workspace-public-key','workspace-fingerprint','private-material','active','1','2','2'); + INSERT INTO workspace_signing_identity_provisioning_operations(operation_key,request_fingerprint,operation_kind,workspace_id,key_id,private_material_ref,actor_account_id,state,created_at,completed_at) + VALUES('case-provision','original-provision','existing_workspace','case-workspace','case-key','private-material','case-owner','completed','1','2'); + INSERT INTO workspace_signing_identity_audit(event_id,workspace_id,key_id,action,public_key_fingerprint,actor_account_id,created_at) + VALUES('case-key-audit','case-workspace','case-key','provisioned','workspace-fingerprint','case-owner','2'); + INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,state,authentication_mode,workspace_key_id,workspace_public_key_fingerprint,workspace_trust_id,created_at,updated_at) + VALUES('case-workspace','case-runtime','Verified runtime','https://runtime.invalid','runtime-public-key','runtime-fingerprint','case-binding','verified','workspace_identity','case-key','workspace-fingerprint','case-trust','1','2'), + ('case-workspace','legacy-runtime','Legacy runtime','https://legacy.invalid','legacy-public-key','legacy-fingerprint','legacy-binding','configured','legacy_server_issuer',NULL,NULL,NULL,'1','2'); + INSERT INTO workspace_runtime_verifications(workspace_id,runtime_id,binding_id,workspace_key_id,workspace_public_key_fingerprint,workspace_trust_id,runtime_public_key_fingerprint,challenge_id,state,last_outcome,verified_at,checked_at) + VALUES('case-workspace','case-runtime','case-binding','case-key','workspace-fingerprint','case-trust','runtime-fingerprint','case-challenge','verified','original-verification','2','2'); + INSERT INTO workspace_runtime_binding_audit(workspace_id,runtime_id,actor_account_id,action,old_fingerprint,new_fingerprint,binding_id,at) + VALUES('case-workspace','case-runtime','case-owner','created',NULL,'runtime-fingerprint','case-binding','1'); + "#).unwrap(); + if version == 90 { + migrate_runtime_binding_connections_v89_to_v90(&conn).unwrap(); + verify_runtime_binding_connection_schema(&conn).unwrap(); + } + assert_eq!(current_schema_version(&conn).unwrap(), version); + assert_eq!( + conn.query_row( + "SELECT MAX(version) FROM ticket_schema_migrations", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 7 + ); + verify_workspace_resource_constraints(&conn).unwrap(); + (dir, path) + } + + fn stored_fixture_rows(conn: &Connection, table: &str) -> Vec> { + // Compare preserved original assignment evidence, not newly added tracking columns. + let columns = if table == "ticket_worker_assignments" { + "workspace_id,ticket_id,assignment_id,role,principal_kind,principal_id,runtime_id,worker_id,assigned_by,assigned_at" + } else { + "*" + }; + fixture_query_rows( + conn, + &format!("SELECT {columns} FROM {table} ORDER BY rowid"), + ) + } + + fn fixture_query_rows(conn: &Connection, sql: &str) -> Vec> { + let mut stmt = conn.prepare(sql).unwrap(); + let columns = stmt.column_count(); + stmt.query_map([], |row| (0..columns).map(|i| row.get(i)).collect()) + .unwrap() + .collect::>() + .unwrap() + } + + // Project schema-89 live conclusions into the exact frozen archive layout. + // At schema 90+ the same values must already be historical, never live auth. + fn fixture_auth_rows(conn: &Connection) -> Vec>> { + let (binding_evidence, verification_evidence) = if current_schema_version(conn).unwrap() + == 89 + { + ( + "SELECT workspace_id,runtime_id,binding_id,state,workspace_key_id,workspace_public_key_fingerprint,workspace_trust_id,updated_at FROM workspace_runtime_bindings ORDER BY runtime_id", + "SELECT * FROM workspace_runtime_verifications ORDER BY runtime_id", + ) + } else { + ( + "SELECT * FROM workspace_runtime_binding_verification_archive ORDER BY runtime_id", + "SELECT * FROM workspace_runtime_verification_archive ORDER BY runtime_id", + ) + }; + ["SELECT workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at,revoked_at FROM workspace_runtime_bindings ORDER BY runtime_id", binding_evidence, verification_evidence] + .map(|sql| fixture_query_rows(conn, sql)).to_vec() + } + + #[test] + fn schema_v89_runs_auth_then_cases_preserving_legacy_states_evidence_and_responsibility() { + assert_legacy_cases_preserved(89); + } + + #[test] + fn schema_v90_cases_preserve_auth_archive_legacy_states_evidence_and_responsibility() { + assert_legacy_cases_preserved(90); + } + + fn assert_legacy_cases_preserved(version: i64) { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(version); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + for has_mr in [false, true] { + let id = format!("{state}-{has_mr}"); + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,queued_by,queued_at,resolution,readiness,assignee,created_at,updated_at) + VALUES('case-workspace',?1,?1,?1,?2,'task','P2','Original requirements',?3,1,?4,?5,'Original closure text','legacy-readiness','legacy-assignee','1','2')", + params![id, if matches!(state,"done"|"closed") { "closed" } else { "open" }, state, + (state=="queued").then_some("queue-author"), (state=="queued").then_some("queue-time")]).unwrap(); + conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) + VALUES('case-workspace','case-runtime',?1,?1,'normal','1','1')", [&id]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) + VALUES('case-workspace',?1,?1,'worker','worker','case-runtime',?1,'original-actor','1')", [&id]).unwrap(); + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,updated_at) + VALUES('case-workspace',?1,?1,'worker','worker','case-runtime',?1,'1')", [&id]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,event_id,assignment_id,role,action,actor,created_at,operation_id) + VALUES('case-workspace',?1,?1,?1,'worker','assigned','original-actor','1',?1)", [&id]).unwrap(); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,runtime_id,worker_id,assignment_id,created_at,request_fingerprint,claim_state) + VALUES('case-workspace',?1,'assign',?1,'worker','case-runtime',?1,?1,'1','original-claim','committed')", [&id]).unwrap(); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,reason,state_field,heading,body) + VALUES('case-workspace',?1,0,'state_changed','original-author','1','planning',?2,'Original decision','state','Original heading','Original thread')", params![id,state]).unwrap(); + conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) + VALUES('case-workspace',?1,0,'operation_key','original-operation')", [&id]).unwrap(); + conn.execute("INSERT INTO typed_ticket_event_references(workspace_id,ticket_id,event_index,ordinal,kind,target) + VALUES('case-workspace',?1,0,0,'commit','original-ref')", [&id]).unwrap(); + conn.execute("INSERT INTO typed_ticket_artifacts(workspace_id,ticket_id,relative_path,content) + VALUES('case-workspace',?1,'original.txt',?2)", params![id,b"original artifact".as_slice()]).unwrap(); + if has_mr { + conn.execute("INSERT INTO typed_ticket_targets(workspace_id,ticket_id,ordinal,repository_key,ref_selector,access) + VALUES('case-workspace',?1,0,'main','develop','read_write')", [&id]).unwrap(); + conn.execute("INSERT INTO merge_requests(workspace_id,merge_request_id,repository_id,state,selector_from,selector_to,created_at,updated_at) + VALUES('case-workspace',?1,'case-repo','merged','work/original','develop','1','2')", [&id]).unwrap(); + conn.execute("INSERT INTO merge_request_ticket_relations(workspace_id,merge_request_id,ticket_id,relation_kind,created_at) + VALUES('case-workspace',?1,?1,'implements','1')", [&id]).unwrap(); + let evidence = merge_request::MergeEvent { + event_id: id.clone(), + sequence: 1, + operation_id: id.clone(), + approval_event_id: "original-approval".into(), + approved_source_ref: "original-source".into(), + target_ref_before: "original-target".into(), + target_ref_after: "original-result".into(), + strategy: merge_request::MergeStrategy::FastForward, + resolution: merge_request::ConflictResolution::None, + merged_by: merge_request::WorkerIdentity { + runtime_id: "original-runtime".into(), + worker_id: "original-orchestrator".into(), + }, + created_at: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + }; + conn.execute("INSERT INTO merge_request_thread_events(workspace_id,merge_request_id,event_id,sequence,kind,payload_json,operation_id,created_at) + VALUES('case-workspace',?1,?1,1,'merge',?2,?1,'2')", params![id,serde_json::to_string(&evidence).unwrap()]).unwrap(); + } + } + } + conn.execute_batch("INSERT INTO worker_control_grants(workspace_id,grant_id,controller_runtime_id,controller_worker_id,subject_runtime_id,subject_worker_id,relation,origin,permissions_json,operation_id,created_at) + VALUES('case-workspace','original-control','case-runtime','planning-false','case-runtime','closed-true','ticket','user','[\"stop\",\"observe\"]','original-grant','1'); + INSERT INTO worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key,request_id,expected_token,settled) + VALUES('case-workspace','case-runtime','closed-true','original-domain','original-restore','original-token',0); + INSERT INTO workdir_registry(workspace_id,workdir_id,source_kind,runtime_id,repository_id,creation_selector,creation_ref,materialization_status,cleanliness,created_at,updated_at) + VALUES('case-workspace','original-workdir','repository','case-runtime','case-repo','develop','original-ref','present','clean','1','1'); + INSERT INTO worker_workdir_links(workspace_id,runtime_id,worker_id,workdir_id,alias,capabilities,connection_id,linked_at) + VALUES('case-workspace','case-runtime','closed-true','original-workdir','checkout','all','original-connection','1');").unwrap(); + // Queued intent with and without a pending claim must survive, but no + // assignment, Worker create/restore or grant is synthesized by migration. + for id in ["pending-queue", "only-queue"] { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,queued_by,queued_at) + VALUES('case-workspace',?1,?1,?1,'open','task','P2','','queued',1,'queue-author','queue-time')", [id]).unwrap(); + } + conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) + VALUES('case-workspace','case-runtime','pending-worker','Pending','normal','1','1')", []).unwrap(); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,runtime_id,worker_id,created_at,request_fingerprint,claim_state) + VALUES('case-workspace','pending-claim','assign','pending-queue','worker','case-runtime','pending-worker','1','pending-fingerprint','pending')", []).unwrap(); + // Compose real retained Ticket8 classification data across the full + // six-state × MR matrix, before either auth90 or case91 runs. + install_retained_ticket_tags8(&conn); + let retained_tags = seed_retained_classification(&conn, "case-workspace", "done-true"); + conn.execute("INSERT OR IGNORE INTO typed_ticket_tags(workspace_id,ticket_id,tag_id) + SELECT workspace_id,ticket_id,?1 FROM typed_tickets WHERE workspace_id='case-workspace'", + [&retained_tags.active_ids[0]]).unwrap(); + let unchanged = [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + "typed_ticket_labels", + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_event_references", + "typed_ticket_artifacts", + "typed_ticket_targets", + "ticket_worker_assignments", + "ticket_current_worker_assignments", + "ticket_worker_assignment_events", + "ticket_assignment_operations", + "worker_registry", + "worker_create_reservations", + "worker_restore_intents", + "workdir_registry", + "worker_workdir_links", + "worker_control_grants", + "merge_requests", + "merge_request_ticket_relations", + "merge_request_thread_events", + "workspace_runtime_binding_audit", + "workspace_signing_identities", + "workspace_signing_identity_audit", + "workspace_signing_identity_provisioning_operations", + ]; + let before = unchanged.map(|table| stored_fixture_rows(&conn, table)); + let auth_before = fixture_auth_rows(&conn); + assert_eq!( + auth_before.iter().map(Vec::len).collect::>(), + [2, 2, 1] + ); + let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); + assert_eq!(plan.current_schema_version, version); + assert_eq!(plan.target_schema_version, LATEST_SCHEMA_VERSION); + assert_eq!( + plan.migrations + .iter() + .map(|step| step.version) + .collect::>(), + if version == 89 { + vec![90, 91, 92, 93] + } else { + vec![91, 92, 93] + } + ); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); + assert_eq!(fixture_auth_rows(conn), auth_before); + verify_runtime_binding_connection_schema(conn)?; + ticket::verify_sqlite_ticket_schema(conn).unwrap(); + assert_eq!(workspace_schema_migration_history(conn)?.into_iter().filter(|(version, _)| *version >= 90).collect::>(), + vec![(90, "Runtime binding connection configuration and historical verification archive".into()), + (91, "Ticket open/closed cases and retained routing evidence".into()), + (92, "Ticket-owned assignment tracking".into()), + (93, "Durable assignment deliveries and source-bound startup fences".into())]); + for (table, rows) in unchanged.iter().zip(before.iter()) { + assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); + } + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_work_releases", + [], + |row| row.get::<_, i64>(0) + )?, + 4 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_active_worker_assignments", + [], + |row| row.get::<_, i64>(0) + )?, + 8 + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?, + 0 + ); + Ok(()) + }) + .unwrap(); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + for has_mr in [false, true] { + let id = format!("{state}-{has_mr}"); + let ticket = backend.show(id.clone().into()).unwrap(); + assert!(!ticket.tags.is_empty(), "matrix classification {id}"); + assert_eq!(ticket.document.body.as_str(), "Original requirements"); + let snapshot = ticket.legacy_workflow_snapshot.as_ref().unwrap(); + assert_eq!(snapshot.workflow_state, state); + assert_eq!( + snapshot.queued_by.as_deref(), + (state == "queued").then_some("queue-author") + ); + assert_eq!( + snapshot.queued_at.as_deref(), + (state == "queued").then_some("queue-time") + ); + assert_eq!( + snapshot.resolution.as_deref(), + Some("Original closure text") + ); + assert_eq!(ticket.meta.assignee.as_deref(), Some("legacy-assignee")); + if matches!(state, "done" | "closed") { + assert_eq!(ticket.meta.workflow_state, ticket::TicketState::Closed); + assert_eq!( + ticket.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Unknown, + "MR evidence does not infer fulfillment" + ); + assert_eq!(ticket.case_history.len(), 1); + let reopened = update_test_case( + &backend, + &id, + &format!("reopen-{id}"), + ticket::TicketCaseAction::Reopen, + ); + assert_eq!(reopened.meta.workflow_state, ticket::TicketState::Open); + assert_eq!(reopened.case_history.len(), 2); + assert_eq!(reopened.case_history[0], ticket.case_history[0]); + assert_eq!(reopened.tags, ticket.tags); + assert_eq!( + reopened + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .collect::>(), + ticket + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .collect::>() + ); + assert!( + store + .get_active_ticket_worker_assignment("case-workspace", &id) + .unwrap() + .is_none() + ); + assert_eq!( + store + .get_current_ticket_worker_assignment("case-workspace", &id) + .unwrap() + .unwrap() + .assignment_id, + id + ); + assert!( + store + .delete_worker_registry( + "case-workspace", + &RuntimeWorkerRef::new("case-runtime", &id) + ) + .is_ok() + ); + } else { + assert_eq!(ticket.meta.workflow_state, ticket::TicketState::Open); + assert!(ticket.case_history.is_empty()); + assert!( + store + .delete_worker_registry( + "case-workspace", + &RuntimeWorkerRef::new("case-runtime", &id) + ) + .is_err() + ); + } + } + } + for id in ["pending-queue", "only-queue"] { + let queued = backend.show(id.into()).unwrap(); + assert_eq!(queued.meta.workflow_state, ticket::TicketState::Open); + let snapshot = queued.legacy_workflow_snapshot.unwrap(); + assert_eq!(snapshot.workflow_state, "queued"); + assert_eq!(snapshot.queued_by.as_deref(), Some("queue-author")); + assert_eq!(snapshot.queued_at.as_deref(), Some("queue-time")); + assert!(queued.case_history.is_empty()); + } + assert!( + store + .get_current_ticket_worker_assignment("case-workspace", "only-queue") + .unwrap() + .is_none() + ); + assert!( + store + .get_active_ticket_worker_assignment("case-workspace", "pending-queue") + .unwrap() + .is_none() + ); + let pending = store + .get_ticket_assignment_operation("case-workspace", "pending-claim") + .unwrap() + .unwrap(); + assert_eq!(pending.claim_state, "pending"); + assert_eq!( + pending.request_fingerprint.as_deref(), + Some("pending-fingerprint") + ); + drop(store); + drop(backend); + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + reopened + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); + assert_eq!(fixture_auth_rows(conn), auth_before); + Ok(()) + }) + .unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + let reopened_case = backend.show("done-true".into()).unwrap(); + assert_eq!(reopened_case.meta.workflow_state, ticket::TicketState::Open); + assert_eq!(reopened_case.case_history.len(), 2); + assert_eq!( + reopened_case.case_history[0].action, + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown + } + ); + assert!( + reopened + .get_active_ticket_worker_assignment("case-workspace", "done-true") + .unwrap() + .is_none() + ); + assert_eq!( + reopened + .get_ticket_assignment_operation("case-workspace", "pending-claim") + .unwrap() + .unwrap(), + pending + ); + } + + #[test] + fn schema_v91_resumes_after_shared_ticket_migration_without_duplicating_judgments() { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(90); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES('case-workspace','terminal','terminal','Terminal','closed','task','P2','','done',1); + DROP VIEW ticket_active_worker_assignments; + CREATE TABLE ticket_active_worker_assignments(collision TEXT);").unwrap(); + install_retained_ticket_tags8(&conn); + let _tags = seed_retained_classification(&conn, "case-workspace", "terminal"); + let tags_before = classification_rows(&conn); + let auth_before = fixture_auth_rows(&conn); + let error = migrate_ticket_cases_v90_to_v91(&conn).unwrap_err(); + assert!(error.to_string().contains("DROP TABLE")); + assert_eq!(current_schema_version(&conn).unwrap(), 90); + assert_eq!(fixture_auth_rows(&conn), auth_before); + assert_eq!(classification_rows(&conn), tags_before); + // The shared transaction completed, but Workspace did not advertise a + // composed schema. Its immutable evidence remains the retry authority. + let before = stored_fixture_rows(&conn, "typed_ticket_case_history"); + assert_eq!(before.len(), 1); + conn.execute_batch("DROP TABLE ticket_active_worker_assignments;") + .unwrap(); + migrate_ticket_cases_v90_to_v91(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 91); + assert_eq!(fixture_auth_rows(&conn), auth_before); + assert_eq!(classification_rows(&conn), tags_before); + assert_eq!( + stored_fixture_rows(&conn, "typed_ticket_case_history"), + before + ); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + assert_eq!( + backend + .show("terminal".into()) + .unwrap() + .current_closure + .unwrap() + .reason, + ticket::TicketClosureReason::Unknown + ); + assert!( + store + .get_active_ticket_worker_assignment("case-workspace", "terminal") + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn claims_and_compensation_do_not_change_case_state_or_resume_closed_work() { + let (dir, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "ticket", "open"); + let record = manual_worker_assignment("ticket", "assignment"); + let fingerprint = manual_ticket_assignment_fingerprint(&record, None).unwrap(); + store + .reserve_ticket_assignment_operation( + "w", + "claim", + "ticket", + "r", + Some("worker"), + &fingerprint, + "1", + ) + .unwrap(); + let backend = + ticket::SqliteTicketBackend::open_verified(dir.path().join("workspace.db"), "w") + .unwrap(); + use ticket::TicketBackend; + assert_eq!( + backend.show("ticket".into()).unwrap().meta.workflow_state, + ticket::TicketState::Open + ); + let closed = update_test_case( + &backend, + "ticket", + "close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + assert!( + store + .get_pending_ticket_assignment_operation_for_worker( + "w", + &RuntimeWorkerRef::new("r", "worker") + ) + .unwrap() + .is_none() + ); + assert!( + store + .bind_ticket_assignment_operation_worker("w", "claim", "worker") + .is_err() + ); + assert!( + store + .start_ticket_with_worker_assignment(&record, "event", "claim", None) + .is_err() + ); + store + .rollback_ticket_assignment_operation("w", "claim") + .unwrap(); + assert_eq!( + backend.show("ticket".into()).unwrap().meta.workflow_state, + ticket::TicketState::Closed + ); + assert_eq!( + backend.show("ticket".into()).unwrap().case_history, + closed.case_history + ); + assert_eq!( + store + .get_ticket_assignment_operation("w", "claim") + .unwrap() + .unwrap() + .claim_state, + "failed" + ); + assert!( + store + .get_current_ticket_worker_assignment("w", "ticket") + .unwrap() + .is_none() + ); + // Explicit reopening also leaves the failed claim receipt inert. + update_test_case( + &backend, + "ticket", + "reopen", + ticket::TicketCaseAction::Reopen, + ); + assert!( + store + .start_ticket_with_worker_assignment(&record, "event", "claim", None) + .is_err() + ); + } + + #[test] + fn schema_v91_fresh_and_retained_cases_and_auth_enforce_equivalent_constraints() { + // Compare SQLite boundary contracts, including guards lost by a parent + // table rebuild, rather than only the latest version marker. Redundant + // indexes with the same uniqueness contract are one semantic constraint. + fn signature(conn: &Connection, object: &str) -> Vec> { + [ + "SELECT name,type,CAST(\"notnull\" AS TEXT),COALESCE(dflt_value,''),CAST(pk AS TEXT) FROM pragma_table_info(?1) ORDER BY name", + "SELECT \"table\",\"from\",\"to\",on_update,on_delete,match,CAST(seq AS TEXT) FROM pragma_foreign_key_list(?1) ORDER BY \"table\",\"from\",\"to\",seq", + "SELECT DISTINCT CAST(i.\"unique\" AS TEXT),CAST(i.partial AS TEXT),(SELECT group_concat(name,',') FROM (SELECT name FROM pragma_index_info(i.name) ORDER BY seqno)) FROM pragma_index_list(?1) AS i ORDER BY 1,2,3", + "SELECT name,sql FROM sqlite_schema WHERE (type='trigger' AND tbl_name=?1) OR (type='view' AND name=?1) ORDER BY name", + ].into_iter().flat_map(|sql| { + let mut stmt = conn.prepare(sql).unwrap(); + let count = stmt.column_count(); + stmt.query_map([object], |row| { + (0..count).map(|i| row.get::<_, String>(i)).collect::>>() + }).unwrap().map(|row| row.unwrap().into_iter().map(|value| { + value.chars().filter(|c| !c.is_whitespace() && *c != '"').collect() + }).collect()).collect::>>() + }).collect() + } + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("equivalence.db"); + prepare_retained_schema(&path, 90); + let upgraded = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap(); + for object in [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + "typed_tickets", + "typed_ticket_targets", + "typed_ticket_case_metadata", + "typed_ticket_case_history", + "typed_ticket_legacy_workflow_snapshot", + "ticket_current_worker_assignments", + "ticket_assignment_work_releases", + "ticket_active_worker_assignments", + "worker_registry", + "workspace_runtime_bindings", + "workspace_runtime_binding_verification_archive", + "workspace_runtime_verification_archive", + "workspace_runtime_binding_audit", + "workspace_signing_identities", + "workspace_signing_identity_provisioning_operations", + "workspace_signing_identity_audit", + ] { + let expected = fresh.with_conn(|conn| Ok(signature(conn, object))).unwrap(); + let actual = upgraded + .with_conn(|conn| Ok(signature(conn, object))) + .unwrap(); + assert!(!expected.is_empty(), "missing fresh schema object {object}"); + assert_eq!(actual, expected, "schema-91 boundary {object}"); + } + for store in [&fresh, &upgraded] { + store.with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); + verify_workspace_resource_constraints(conn)?; + verify_runtime_binding_connection_schema(conn)?; + ticket::verify_sqlite_ticket_schema(conn).unwrap(); + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) + VALUES('guard-owner','user','guard-owner','Owner','1','1'); + INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) + VALUES('guard-workspace','guard-owner','Guard','active','1','1'); + INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES('guard-workspace','guard-ticket','guard-ticket','Guard','open','task','P2','','open',1);")?; + assert!(conn.execute("UPDATE typed_tickets SET workflow_state='done' WHERE ticket_id='guard-ticket'", []).is_err()); + assert!(conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES('guard-workspace','bad-ticket','bad-ticket','Bad','open','task','P2','','planning',1)", []).is_err()); + assert!(conn.execute("INSERT INTO workspace_runtime_bindings(workspace_id,runtime_id,display_name,base_url,public_key,public_key_fingerprint,binding_id,authentication_mode,created_at,updated_at) + VALUES('guard-workspace','bad-runtime','Bad','https://invalid.invalid','key','fingerprint','binding','invalid-mode','1','1')", []).is_err()); + Ok(()) + }).unwrap(); + } + } + + #[test] + fn schema_v91_case_constraint_verification_rejects_legacy_view_predicates() { + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); + store.with_conn(|conn| { + conn.execute_batch("DROP VIEW ticket_active_worker_assignments; + CREATE VIEW ticket_active_worker_assignments AS + SELECT current.* FROM ticket_current_worker_assignments AS current + JOIN typed_tickets AS ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id + WHERE ticket.workflow_state NOT IN ('done','closed');")?; + assert!(verify_workspace_resource_constraints(conn).unwrap_err().to_string().contains("schema 91")); + Ok(()) + }).unwrap(); + } + // Equivalent to the reported closed T-12 + retained Coder data. The // migration fixture is a real pre-cutover schema, not the live Workspace. #[test] @@ -18851,7 +20434,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx matches!(&blockers[..], [crate::retention::WorkerRemovalBlocker::Hold,crate::retention::WorkerRemovalBlocker::UnfinishedWork { assignment_id,ticket_id }] if assignment_id=="current_assignment" && ticket_id=="closed-ticket") ); conn.execute_batch( - "UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id='closed-ticket'; + "UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id='closed-ticket'; DELETE FROM worker_registry WHERE worker_id='old-coder';", ) .unwrap(); @@ -18887,7 +20470,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx // boundary under test. Targets need not be materialized by a Runtime. async fn manual_worker_start_store() -> (tempfile::TempDir, SqliteWorkspaceStore) { let dir = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "w".into(), @@ -18909,6 +20492,31 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (dir, store) } + fn update_test_case( + backend: &ticket::SqliteTicketBackend, + id: &str, + operation: &str, + action: ticket::TicketCaseAction, + ) -> ticket::Ticket { + use ticket::TicketBackend; + let current = backend.show(id.into()).unwrap(); + backend + .update_case( + id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(¤t), + expected_state: current.meta.workflow_state, + expected_closure_operation_key: current.closure_operation_key, + operation_key: operation.into(), + action, + explanation: "Store fixture case judgment".into(), + references: vec![], + author: Some("store-fixture-author".into()), + }, + ) + .unwrap() + } + fn seed_manual_start_ticket(store: &SqliteWorkspaceStore, id: &str, state: &str) { store.with_conn(|conn| { conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,updated_at) @@ -18939,8 +20547,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx async fn pending_claims_serialize_competing_ticket_and_known_worker_reservations() { for (same_ticket, same_operation) in [(true, false), (false, false), (true, true)] { let (dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); - seed_manual_start_ticket(&store, "second", "ready"); + seed_manual_start_ticket(&store, "first", "open"); + seed_manual_start_ticket(&store, "second", "open"); store.with_conn(|conn| { conn.execute_batch("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','other','Other','normal','1','1');")?; @@ -18950,7 +20558,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let threads = (0..2) .map(|index| { let store = - SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")) + .unwrap(); let barrier = barrier.clone(); std::thread::spawn(move || { barrier.wait(); @@ -19009,10 +20618,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } #[tokio::test] - async fn pending_claims_fence_all_assignment_writers_and_worker_binding_until_owner_commits() { + async fn pending_claims_fence_work_writers_but_allow_delegation_clear() { let (_dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); - seed_manual_start_ticket(&store, "second", "ready"); + seed_manual_start_ticket(&store, "first", "open"); + seed_manual_start_ticket(&store, "second", "open"); let first = manual_worker_assignment("first", "first-assignment"); let orchestrator = TicketRoleAssignmentRecord { role: TicketAssignmentRole::Orchestrator, @@ -19061,7 +20670,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx "2", None ) - .is_err() + .unwrap() ); assert!( store @@ -19183,7 +20792,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn manual_claim_consumes_matching_binding_fingerprint_and_replays_committed_receipt() { let (_dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); + seed_manual_start_ticket(&store, "first", "open"); let record = manual_worker_assignment("first", "assignment"); let fingerprint = manual_ticket_assignment_fingerprint(&record, Some("bindings-a")).unwrap(); @@ -19276,7 +20885,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn manual_claim_recovery_survives_reopen_and_fences_stale_item_finalization() { let (dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); + seed_manual_start_ticket(&store, "first", "open"); let record = manual_worker_assignment("first", "assignment"); let fingerprint = manual_ticket_assignment_fingerprint(&record, Some("bindings")).unwrap(); store @@ -19291,7 +20900,13 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ) .unwrap(); let recovery = TicketWorkerClaimRecovery { - content_digest: "first:0".into(), + content_digest: store + .with_conn(|conn| { + ticket::sqlite_ticket_content_digest(conn, "w", "first") + .map_err(|error| Error::Store(error.to_string())) + }) + .unwrap() + .unwrap(), selected: vec![], original_links: vec![], effective_links: vec![], @@ -19309,13 +20924,15 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .retain_ticket_claim_recovery("w", "claim", &fingerprint, &changed) .is_err() ); - let reopened = SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + let reopened = + SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap(); assert_eq!( reopened.get_ticket_claim_recovery("w", "claim").unwrap(), Some(recovery) ); reopened.with_conn(|conn| { - conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('w','first',1,'item_edit','Ticket targets changed')",[])?; Ok(()) + conn.execute_batch("UPDATE typed_tickets SET title='Changed requirements' WHERE workspace_id='w' AND ticket_id='first'; + INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('w','first',1,'item_edit','Ticket requirements changed');")?; Ok(()) }).unwrap(); assert!( reopened @@ -19343,7 +20960,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn manual_claim_failed_receipt_survives_reopen_and_rollback_without_restarting() { let (dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); + seed_manual_start_ticket(&store, "first", "open"); let record = manual_worker_assignment("first", "assignment"); let fingerprint = manual_ticket_assignment_fingerprint(&record, Some("bindings")).unwrap(); store @@ -19366,7 +20983,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx store .rollback_ticket_assignment_operation("w", "claim") .unwrap(); - let reopened = SqliteWorkspaceStore::open(dir.path().join("workspace.db")).unwrap(); + let reopened = + SqliteWorkspaceStore::open_for_test(dir.path().join("workspace.db")).unwrap(); let receipt = reopened .get_ticket_assignment_operation("w", "claim") .unwrap() @@ -19418,15 +21036,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } #[tokio::test] - async fn manual_worker_start_accepts_nonterminal_states_with_idempotent_assignment_and_audit() { - for state in [ - "planning", - "ready", - "queued", - "inprogress", - "done", - "closed", - ] { + async fn manual_worker_start_is_case_neutral_with_idempotent_assignment_and_audit() { + for state in ["open", "closed"] { for user_principal in [false, true] { let (_dir, store) = manual_worker_start_store().await; seed_manual_start_ticket(&store, "ticket", state); @@ -19438,7 +21049,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } let result = store.start_ticket_with_worker_assignment(&record, "event", "operation", None); - if matches!(state, "done" | "closed") { + if state == "closed" { assert!(result.is_err(), "{state}"); } else { assert_eq!(result.unwrap(), record, "{state}"); @@ -19475,12 +21086,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (SELECT COUNT(*) FROM ticket_worker_assignments), (SELECT COUNT(*) FROM ticket_assignment_operations) FROM typed_tickets WHERE ticket_id='ticket'", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?; - if matches!(state, "done" | "closed") { + if state == "closed" { assert_eq!((persisted.as_str(), events, assignments, operations), (state,0,0,0)); } else { - assert_eq!((persisted.as_str(), events, assignments, operations), ("inprogress",1,1,1)); - let transition: (String,String) = conn.query_row("SELECT from_state,to_state FROM typed_ticket_events", [], |row| Ok((row.get(0)?,row.get(1)?)))?; - assert_eq!(transition, (state.into(), "inprogress".into())); + assert_eq!((persisted.as_str(), events, assignments, operations), ("open",1,1,1)); + let transition: (Option,Option,String) = conn.query_row("SELECT from_state,to_state,kind FROM typed_ticket_events", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?; + assert_eq!(transition, (None, None, "assignment".into())); assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='assignment_role'", [], |row| row.get::<_,String>(0))?, "worker"); } Ok(()) @@ -19493,8 +21104,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx async fn manual_worker_start_rejects_busy_worker_without_mutation_and_requires_new_reopen_identity() { let (_dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); - seed_manual_start_ticket(&store, "second", "queued"); + seed_manual_start_ticket(&store, "first", "open"); + seed_manual_start_ticket(&store, "second", "open"); let first = manual_worker_assignment("first", "first-assignment"); let second = manual_worker_assignment("second", "second-assignment"); store @@ -19518,7 +21129,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx [], |row| row.get::<_, String>(0) )?, - "queued" + "open" ); assert_eq!( conn.query_row( @@ -19530,7 +21141,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); conn.execute_batch( "UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id='first'; - UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id='first';", + UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id='first';", )?; Ok(()) }) @@ -19593,7 +21204,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx use ticket::TicketBackend; let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("workspace.db"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "w".into(), @@ -19633,12 +21244,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .set_current_ticket_worker_assignment(&next, None, "e2", "op2", false) .is_err() ); - backend - .close( - first.id.clone().into(), - ticket::MarkdownText::new("Closed without integration proof"), - ) - .unwrap(); + update_test_case( + &backend, + &first.id, + "close-first", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + ); assert_eq!( store .get_current_ticket_worker_assignment("w", &first.id) @@ -19654,9 +21267,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx store .set_current_ticket_worker_assignment(&next, None, "e2", "op2", false) .unwrap(); - // Generic Ticket tools do not expose terminal -> planning. Seed the - // authority state boundary to prove future/reconciled reopen is inert. - store.with_conn(|conn| { conn.execute("UPDATE typed_tickets SET workflow_state='planning' WHERE workspace_id='w' AND ticket_id=?1", [&first.id])?; Ok(()) }).unwrap(); + update_test_case( + &backend, + &first.id, + "reopen-first", + ticket::TicketCaseAction::Reopen, + ); assert!( store .get_active_ticket_worker_assignment("w", &first.id) @@ -19683,9 +21299,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .is_err(), "another Ticket still occupies the Worker" ); - backend - .close(second.id.clone().into(), ticket::MarkdownText::new("Ended")) - .unwrap(); + update_test_case( + &backend, + &second.id, + "close-second", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + ); store .set_current_ticket_worker_assignment(&restarted, None, "e3", "op3", false) .unwrap(); @@ -19711,7 +21332,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() { let dir = tempfile::tempdir().unwrap(); let db = dir.path().join("server.db"); - let store = SqliteWorkspaceStore::open(&db).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&db).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-a".to_string(), @@ -19731,9 +21352,9 @@ 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); + ('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', 'open', 'task', 'normal', '', 'open', 1), + ('workspace-a', 'ticket-2', 'ticket-2', 'Ticket 2', 'open', 'task', 'normal', '', 'open', 1), + ('workspace-a', 'ticket-3', 'ticket-3', 'Ticket 3', 'open', 'task', 'normal', '', 'open', 1); INSERT INTO worker_registry ( workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at ) VALUES @@ -19900,7 +21521,7 @@ INSERT INTO worker_registry ( ) .unwrap(); drop(store); - let store = SqliteWorkspaceStore::open(&db).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&db).unwrap(); let pending = store .get_ticket_assignment_operation("workspace-a", "reserved-operation") .unwrap() @@ -19948,30 +21569,60 @@ INSERT INTO worker_registry ( .unwrap(), None ); - store - .rollback_ticket_assignment_operation("workspace-a", "reserved-operation") - .unwrap(); + // A committed receipt is not rollbackable evidence. Compensation is a + // new unassign operation and preserves the original identity/audit. + assert!( + store + .rollback_ticket_assignment_operation("workspace-a", "reserved-operation") + .is_err() + ); assert_eq!( store .get_ticket_assignment_operation("workspace-a", "reserved-operation") - .unwrap(), - None + .unwrap() + .and_then(|operation| operation.assignment_id), + Some("assignment-3".into()) ); assert_eq!( store .get_current_ticket_worker_assignment("workspace-a", "ticket-3") .unwrap(), - None + Some(reserved_assignment.clone()) + ); + assert_eq!( + store + .clear_current_ticket_worker_assignment( + "workspace-a", + "ticket-3", + Some("assignment-3"), + "compensate-reserved-operation", + "compensation-event", + "user-1", + "2026-07-32T00:00:04Z" + ) + .unwrap(), + Some(reserved_assignment) ); assert!( + store + .get_current_ticket_worker_assignment("workspace-a", "ticket-3") + .unwrap() + .is_none() + ); + assert_eq!( store .list_ticket_worker_assignment_events("workspace-a", "ticket-3", 10) .unwrap() - .is_empty() + .iter() + .map(|event| event.action.as_str()) + .collect::>(), + BTreeSet::from(["unassigned", "assigned"]) + ); + assert!( + store + .rollback_ticket_assignment_operation("workspace-a", "reserved-operation") + .is_err() ); - store - .rollback_ticket_assignment_operation("workspace-a", "reserved-operation") - .unwrap(); let events = store .list_ticket_worker_assignment_events("workspace-a", "ticket-1", 10) @@ -19994,7 +21645,7 @@ INSERT INTO worker_registry ( async fn role_assignment_routes_and_manual_start_are_state_fenced_atomically() { let dir = tempfile::tempdir().unwrap(); let db_path = dir.path().join("workspace.db"); - let store = SqliteWorkspaceStore::open(&db_path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&db_path).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-role".to_string(), @@ -20044,7 +21695,7 @@ INSERT INTO worker_registry ( ticket::SqliteTicketBackend::open_verified(&db_path, "workspace-role").unwrap(); let mut input = ticket::NewTicket::new("Role assignment"); input.body = ticket::MarkdownText::new("test"); - input.workflow_state = Some(ticket::TicketWorkflowState::Ready); + input.workflow_state = Some(ticket::TicketState::Open); input.targets = vec![ticket::TicketTarget { repository_key: "main".to_string(), ref_selector: Some("develop".to_string()), @@ -20117,7 +21768,7 @@ INSERT INTO worker_registry ( .unwrap() .meta .workflow_state, - ticket::TicketWorkflowState::Ready + ticket::TicketState::Open ); assert!( store @@ -20153,10 +21804,7 @@ INSERT INTO worker_registry ( .unwrap(); assert_eq!(replayed, coder); let ticket = ticket::TicketBackend::show(&backend, ticket.id.clone().into()).unwrap(); - assert_eq!( - ticket.meta.workflow_state, - ticket::TicketWorkflowState::InProgress - ); + assert_eq!(ticket.meta.workflow_state, ticket::TicketState::Open); assert_eq!( ticket .events @@ -20216,7 +21864,7 @@ INSERT INTO worker_registry ( ticket::TicketBackend::show(&backend, ticket.meta.id.clone().into()).unwrap(); assert_eq!( cancelled_ticket.meta.workflow_state, - ticket::TicketWorkflowState::Ready + ticket::TicketState::Open ); assert_eq!( cancelled_ticket @@ -20286,7 +21934,7 @@ INSERT INTO worker_registry ( 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(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .with_conn(|conn| { conn.execute_batch("DROP TRIGGER ticket_worker_assignments_validate_insert")?; @@ -20295,7 +21943,7 @@ INSERT INTO worker_registry ( .unwrap(); drop(store); - let error = match SqliteWorkspaceStore::open(&path) { + let error = match SqliteWorkspaceStore::open_for_test(&path) { Ok(_) => panic!("missing assignment constraint trigger must fail closed"), Err(error) => error, }; @@ -20311,7 +21959,7 @@ INSERT INTO worker_registry ( 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(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); store .with_conn(|conn| { conn.execute_batch( @@ -20331,7 +21979,7 @@ INSERT INTO workspace_signing_identities ( 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); +) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', 'open', 1); INSERT INTO worker_registry ( workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at ) VALUES ( @@ -20361,7 +22009,7 @@ INSERT INTO workspace_signing_identities ( 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); +) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', '', 'open', 1); INSERT INTO worker_registry ( workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at ) VALUES ( @@ -20375,7 +22023,7 @@ INSERT INTO worker_registry ( .unwrap(); drop(store); - let reopened = SqliteWorkspaceStore::open(&path).unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); reopened .with_conn(|conn| { let retained: i64 = conn.query_row( @@ -20403,7 +22051,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn workspace_bootstrap_validates_key_and_rejects_duplicate_workspace() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store .upsert_account(&AccountRecord { account_id: "owner-account".to_string(), @@ -20535,7 +22183,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn repository_records_round_trip() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -20616,7 +22264,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn memory_authority_records_round_trip_and_close_staging() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -20691,7 +22339,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn legacy_memory_reset_is_atomic_scoped_idempotent_and_exact() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); for (workspace_id, display_name) in [ ("local-dev", "Local Dev"), ("other-workspace", "Other Workspace"), @@ -20858,7 +22506,7 @@ INSERT INTO worker_registry ( async fn worker_workdir_registry_round_trips_and_preserves_pinned_retention() { let temp = tempfile::tempdir().unwrap(); let db = temp.path().join("workspace.db"); - let store = SqliteWorkspaceStore::open(&db).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&db).unwrap(); let workspace = WorkspaceRecord { workspace_id: "local-dev".to_string(), owner_account_id: "owner-account".to_string(), @@ -21227,7 +22875,7 @@ INSERT INTO worker_registry ( ); } drop(store); - let store = SqliteWorkspaceStore::open(&db).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&db).unwrap(); assert_eq!( store .list_worker_workdir_links_page("local-dev", &worker.worker, 1, 0, None) @@ -21257,7 +22905,7 @@ INSERT INTO worker_registry ( let workdir_conflict = store.attach_worker_workdir(&workdir_conflict).unwrap(); drop(store); - let reopened = SqliteWorkspaceStore::open(&db).unwrap(); + let reopened = SqliteWorkspaceStore::open_for_test(&db).unwrap(); assert_eq!( reopened .list_worker_workdir_links("local-dev", &worker.worker) @@ -21275,7 +22923,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn worker_projection_preserves_equal_local_ids_from_distinct_runtimes() { let temp = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(temp.path().join("workspace.db")).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -21401,7 +23049,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn worker_projection_reconciles_snapshots_and_preserves_unavailable_lifecycle() { let temp = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(temp.path().join("workspace.db")).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -21576,7 +23224,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn late_runtime_event_does_not_recreate_retention_deleted_catalog_worker() { let temp = tempfile::tempdir().unwrap(); - let store = SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(temp.path().join("workspace.db")).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "local-dev".to_string(), @@ -21671,7 +23319,7 @@ INSERT INTO worker_registry ( async fn worker_control_grants_are_idempotent_scoped_and_support_internal_invalidation() { let dir = tempfile::tempdir().unwrap(); let database = dir.path().join("control-grants.db"); - let store = SqliteWorkspaceStore::open(&database).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&database).unwrap(); store .upsert_workspace(&WorkspaceRecord { workspace_id: "workspace-control".to_string(), @@ -21741,7 +23389,7 @@ INSERT INTO worker_registry ( ); drop(store); - let store = SqliteWorkspaceStore::open(&database).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&database).unwrap(); assert_eq!( store .list_active_worker_control_grants( @@ -21799,7 +23447,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn account_and_login_records_round_trip() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); assert_eq!(store.schema_version().await.unwrap(), LATEST_SCHEMA_VERSION); let now = "2026-07-22T00:00:00Z".to_string(); let account = AccountRecord { @@ -22158,9 +23806,9 @@ INSERT INTO worker_registry ( async fn backend_job_resource_reserve_converges_across_connections_without_changing_input() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("resource-jobs.sqlite"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); seed_resource_job_workspace(&store).await; - let other = SqliteWorkspaceStore::open(&path).unwrap(); + let other = SqliteWorkspaceStore::open_for_test(&path).unwrap(); let requests = [ resource_job_request("first", "subject-a", "candidate-1"), resource_job_request("followup", "subject-a", "candidate-2"), @@ -22276,7 +23924,7 @@ INSERT INTO worker_registry ( async fn backend_job_resource_retry_and_unknown_are_fenced_by_durable_cleanup() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("cleanup-jobs.sqlite"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); seed_resource_job_workspace(&store).await; let request = resource_job_request("failed", "subject-a", "candidate-1"); let first = store @@ -22382,7 +24030,7 @@ INSERT INTO worker_registry ( .unwrap(); finish_resource_job_cleanup(&store, &second); drop(store); - let restarted = SqliteWorkspaceStore::open(&path).unwrap(); + let restarted = SqliteWorkspaceStore::open_for_test(&path).unwrap(); let active = restarted .find_active_backend_job_for_resource( "resource-workspace", @@ -22442,7 +24090,7 @@ INSERT INTO worker_registry ( async fn backend_job_resource_retry_races_new_intent_atomically_and_remains_bounded() { let dir = tempfile::tempdir().unwrap(); let path = dir.path().join("retry-race.sqlite"); - let store = SqliteWorkspaceStore::open(&path).unwrap(); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); seed_resource_job_workspace(&store).await; // Generic resources use the same common reservation API without domain grants. let mut request = resource_job_request("generic", "subject-a", "candidate-1"); @@ -22465,7 +24113,7 @@ INSERT INTO worker_registry ( let mut followup = request.clone(); followup.job_id = "generic-followup".into(); followup.input = serde_json::json!({"candidate": "new"}); - let other = SqliteWorkspaceStore::open(&path).unwrap(); + let other = SqliteWorkspaceStore::open_for_test(&path).unwrap(); let barrier = std::sync::Barrier::new(2); let (retry, reserve) = std::thread::scope(|scope| { let a = scope.spawn(|| { @@ -22551,7 +24199,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn backend_job_all_older_attempt_cleanups_fence_retry_and_resource_release() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); seed_resource_job_workspace(&store).await; let mut request = resource_job_request("legacy-overlap", "subject-a", "candidate-1"); request.limits.max_attempts = 3; @@ -22675,7 +24323,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn backend_job_empty_batch_surface_refresh_locks_subject_and_defers_candidates() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); seed_resource_job_workspace(&store).await; for surface_state in ["ungenerated", "stale", "failed"] { let subject = format!("subject-{surface_state}"); @@ -22795,7 +24443,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn backend_job_completed_resource_releases_only_after_worker_cleanup() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); seed_resource_job_workspace(&store).await; let request = resource_job_request("completed", "subject-a", "candidate-1"); let first = store @@ -22849,7 +24497,7 @@ INSERT INTO worker_registry ( #[tokio::test] async fn backend_jobs_fence_results_project_ownership_and_bound_retries() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); let workspace_id = "workspace-job"; store .upsert_account(&AccountRecord { diff --git a/crates/workspace-server/src/ticket_item_checker.rs b/crates/workspace-server/src/ticket_item_checker.rs index 4fffb8a35..4b1e394fc 100644 --- a/crates/workspace-server/src/ticket_item_checker.rs +++ b/crates/workspace-server/src/ticket_item_checker.rs @@ -1,8 +1,10 @@ //! Asynchronous advisory checks of saved Ticket content using Backend Jobs. //! //! Authenticated Worker Create/EditItem paths freeze check input and reserve a Job after saving. -//! Failed preparation, execution, or delivery does not roll back the save or change Ticket state, -//! Queue, or assignments. Direct browser edits have no notification Worker and start no check. +//! Failed preparation, execution, or delivery does not roll back the save or change case state +//! or assignments. Direct browser edits have no notification Worker and start no check. +//! Dispatch and timeout processing use bounded admission; overload retains the saved reservation +//! or deadline for diagnosis. An unknown DB outcome is never automatically retried. //! //! Input is limited to saved title/body, content digest, and the prior body/replacement context //! needed for that edit, excluding conversation, user agreements, and external facts. Content diff --git a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs new file mode 100644 index 000000000..69190eb1f --- /dev/null +++ b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs @@ -0,0 +1,560 @@ +// Included by store::tests. Genuine retained six-state Workspace89/90 fixtures +// compose auth90, classification and cases; no lowered current-schema markers. + +// Frozen classification schema from develop b1fe562e (Ticket component 8). +// Deliberate persisted compatibility input, independent of the latest baseline. +fn install_retained_ticket_tags8(conn: &Connection) { + ticket::verify_pre_classification_ticket_schema(conn).unwrap(); + conn.execute_batch(r#" + CREATE TABLE workspace_tags ( + workspace_id TEXT NOT NULL, tag_id TEXT NOT NULL, name TEXT NOT NULL, + normalized_name TEXT NOT NULL, last_operation_key TEXT NOT NULL, + deleted INTEGER NOT NULL CHECK (deleted IN (0,1)), + PRIMARY KEY(workspace_id,tag_id)); + CREATE UNIQUE INDEX workspace_tags_active_name + ON workspace_tags(workspace_id,normalized_name) WHERE deleted=0; + CREATE TABLE typed_ticket_tags ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, tag_id TEXT NOT NULL, + PRIMARY KEY(workspace_id,ticket_id,tag_id), + FOREIGN KEY(workspace_id,ticket_id) REFERENCES typed_tickets(workspace_id,ticket_id) ON DELETE CASCADE, + FOREIGN KEY(workspace_id,tag_id) REFERENCES workspace_tags(workspace_id,tag_id) ON DELETE RESTRICT); + CREATE INDEX typed_ticket_tags_workspace_tag ON typed_ticket_tags(workspace_id,tag_id,ticket_id); + CREATE TABLE workspace_tag_operations ( + workspace_id TEXT NOT NULL, operation_key TEXT NOT NULL, kind TEXT NOT NULL, + target_id TEXT NOT NULL, input_json TEXT NOT NULL, source_context_json TEXT NOT NULL, + before_json TEXT NOT NULL, result_json TEXT NOT NULL, at TEXT NOT NULL, + PRIMARY KEY(workspace_id,operation_key)); + UPDATE ticket_schema_migrations SET version=8; + "#).unwrap(); +} + +#[derive(Clone)] +struct RetainedClassification { + source: std::collections::BTreeMap, + request: ticket::TicketTagSetRequest, + receipt: Vec, + active_ids: Vec, + deleted_id: String, +} + +// Seed actual v8 persisted boundaries rather than invoking a latest-only API on +// a legacy database. Full source context is audit, only authenticated identity +// belongs to the replay fingerprint. It is deliberately not the assigned Worker. +fn seed_retained_classification( + conn: &Connection, + workspace: &str, + id: &str, +) -> RetainedClassification { + let source = std::collections::BTreeMap::from([ + ("source_runtime_id".into(), "case-runtime".into()), + ("source_worker_id".into(), "classifier".into()), + ("assigned_runtime_id".into(), "case-runtime".into()), + ("assigned_worker_id".into(), id.into()), + ("source_actor_role".into(), "ticket_worker".into()), + ( + "source_assignment_id".into(), + format!("old-assignment-{id}"), + ), + ]); + let identity = std::collections::BTreeMap::from([ + ("source_runtime_id", "case-runtime"), + ("source_worker_id", "classifier"), + ]); + let mut receipt = Vec::new(); + for (suffix, name, deleted) in [ + ("ready", "ready", false), + ("closed", "closed", false), + ("old", "Bug", true), + ("replacement", "Bug", false), + ] { + let tag = ticket::WorkspaceTag { + workspace_id: workspace.into(), + id: format!("{id}-{suffix}"), + name: name.into(), + last_operation_key: format!("{id}-{suffix}-create"), + deleted: false, + }; + let request = ticket::WorkspaceTagMutation { + operation_key: tag.last_operation_key.clone(), + action: ticket::WorkspaceTagAction::Create { name: name.into() }, + }; + conn.execute( + "INSERT INTO workspace_tags VALUES(?1,?2,?3,?4,?5,?6)", + params![ + workspace, + tag.id, + name, + name.to_lowercase(), + if deleted { + format!("{id}-delete") + } else { + tag.last_operation_key.clone() + }, + deleted + ], + ) + .unwrap(); + let input = + serde_json::to_string(&("mutate_workspace_tag", "", &request, &identity)).unwrap(); + conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'create',?3,?4,?5,'null',?6,'original-time')", + params![workspace, request.operation_key, tag.id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(&tag).unwrap()]).unwrap(); + if suffix != "replacement" { + receipt.push(tag); + } + } + receipt.sort_by(|a, b| a.id.cmp(&b.id)); + let request = ticket::TicketTagSetRequest { + operation_key: format!("{id}-attach"), + expected_tag_ids: vec![], + tag_ids: receipt.iter().map(|tag| tag.id.clone()).collect(), + }; + let selector: ticket::TicketIdOrSlug = id.into(); + let input = + serde_json::to_string(&("set_ticket_tags", "", &(&selector, &request), &identity)).unwrap(); + conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'set_ticket_tags',?3,?4,?5,'[]',?6,'original-time')", + params![workspace, request.operation_key, id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(&receipt).unwrap()]).unwrap(); + let active: Vec<_> = receipt + .iter() + .filter(|tag| !tag.id.ends_with("-old")) + .cloned() + .collect(); + let old = receipt.iter().find(|tag| tag.id.ends_with("-old")).unwrap(); + let mut deleted = old.clone(); + deleted.deleted = true; + deleted.last_operation_key = format!("{id}-delete"); + let delete_request = ticket::WorkspaceTagMutation { + operation_key: deleted.last_operation_key.clone(), + action: ticket::WorkspaceTagAction::Delete { + tag_id: old.id.clone(), + expected_last_operation_key: old.last_operation_key.clone(), + }, + }; + let input = + serde_json::to_string(&("mutate_workspace_tag", "", &delete_request, &identity)).unwrap(); + conn.execute("INSERT INTO workspace_tag_operations VALUES(?1,?2,'delete',?3,?4,?5,?6,?7,'original-time')", + params![workspace, delete_request.operation_key, old.id, input, serde_json::to_string(&source).unwrap(), serde_json::to_string(old).unwrap(), serde_json::to_string(&deleted).unwrap()]).unwrap(); + for tag in &active { + conn.execute( + "INSERT INTO typed_ticket_tags VALUES(?1,?2,?3)", + params![workspace, id, tag.id], + ) + .unwrap(); + } + conn.execute( + "INSERT INTO typed_ticket_labels VALUES(?1,?2,0,'ready')", + params![workspace, id], + ) + .unwrap(); + let next: i64 = conn.query_row("SELECT COALESCE(MAX(event_index)+1,0) FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2",params![workspace,id],|row|row.get(0)).unwrap(); + for (offset, key, before, after) in [ + ( + 0, + request.operation_key.as_str(), + Vec::new(), + receipt.clone(), + ), + ( + 1, + delete_request.operation_key.as_str(), + receipt.clone(), + active.clone(), + ), + ] { + let body = + serde_json::json!({"operation_key":key,"before":before,"after":after}).to_string(); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,body) VALUES(?1,?2,?3,'tags_changed','classifier','original-time',?4)",params![workspace,id,next+offset,body]).unwrap(); + for (key, value) in &source { + conn.execute( + "INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)", + params![workspace, id, next + offset, key, value], + ) + .unwrap(); + } + } + let deleted_id = old.id.clone(); + RetainedClassification { + source, + request, + receipt, + active_ids: active.iter().map(|tag| tag.id.clone()).collect(), + deleted_id, + } +} + +fn classification_rows(conn: &Connection) -> Vec>> { + [ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_labels", + ] + .map(|table| stored_fixture_rows(conn, table)) + .to_vec() +} + +#[test] +fn workspace90_ticket7_and_tagged8_upgrade_to_cases10_preserving_history_and_auth() { + for component in [7, 8] { + assert_retained_classification_upgrade(90, component); + } +} + +#[test] +fn workspace89_auth_then_cases_preserve_tags_receipts_source_context_and_runtime_archives() { + assert_retained_classification_upgrade(89, 8); +} + +fn assert_retained_classification_upgrade(workspace_version: i64, component: i64) { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(workspace_version); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + // Real six-state parent rows: neither a fresh open/closed schema nor API close. + conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,resolution,created_at,updated_at) + VALUES('case-workspace','closed-legacy','closed-legacy','Retained decision','closed','task','P2','Original requirements','done',1,'Original closure text','1','2'); + INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,reason,state_field,body) + VALUES('case-workspace','closed-legacy',0,'state_changed','original-author','1','inprogress','done','Original decision','state','Original thread'); + INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) + VALUES('case-workspace','case-runtime','recipient','Recipient','normal','1','1'); + INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) + VALUES('case-workspace','closed-legacy','assignment','worker','worker','case-runtime','recipient','original-actor','1'); + INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,updated_at) + VALUES('case-workspace','closed-legacy','assignment','worker','worker','case-runtime','recipient','1');").unwrap(); + ticket::verify_pre_classification_ticket_schema(&conn).unwrap(); + let retained = if component == 8 { + install_retained_ticket_tags8(&conn); + Some(seed_retained_classification( + &conn, + "case-workspace", + "closed-legacy", + )) + } else { + conn.execute( + "INSERT INTO typed_ticket_labels VALUES('case-workspace','closed-legacy',0,'ready')", + [], + ) + .unwrap(); + None + }; + assert_eq!( + conn.query_row( + "SELECT MAX(version) FROM ticket_schema_migrations", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + component + ); + assert!(!table_exists(&conn, "typed_ticket_case_history").unwrap()); + let preserved_tables = [ + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_labels", + "ticket_current_worker_assignments", + "ticket_worker_assignments", + "workspace_signing_identities", + "workspace_signing_identity_audit", + "workspace_signing_identity_provisioning_operations", + "workspace_runtime_binding_audit", + ]; + let preserved = preserved_tables.map(|table| stored_fixture_rows(&conn, table)); + let tags_before = retained.as_ref().map(|_| classification_rows(&conn)); + let auth_before = fixture_auth_rows(&conn); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + store.with_conn(|conn| { + assert_eq!(current_schema_version(conn)?,93); + assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,11); + assert_eq!(fixture_auth_rows(conn),auth_before); + for (table, rows) in preserved_tables.iter().zip(&preserved) { + let mut migrated = stored_fixture_rows(conn, table); + if *table == "ticket_worker_assignments" { + // Component 11 adds handling columns to the same identity row. + // Compare the ten retained identity/principal/Assign columns, + // and separately prove the historical Worker acquired no tracking. + for row in &mut migrated { row.truncate(10); } + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_worker_assignments WHERE tracking_json IS NOT NULL", [], |row| row.get::<_,i64>(0))?, 0); + } + assert_eq!(&migrated, rows, "preserve {table}"); + } + if let Some(rows)=&tags_before {assert_eq!(&classification_rows(conn),rows);} + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_work_releases WHERE assignment_id='assignment'",[],|row|row.get::<_,i64>(0))?,1); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check",[],|row|row.get::<_,i64>(0))?,0); + assert!(!column_exists(conn,"workspace_runtime_bindings","state")?); + ticket::verify_sqlite_ticket_schema(conn).unwrap(); + Ok(()) + }).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + let before = backend.show("closed-legacy".into()).unwrap(); + assert_eq!(before.meta.workflow_state, ticket::TicketState::Closed); + assert_eq!( + before.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Unknown + ); + assert_eq!(before.case_history.len(), 1); + assert_eq!( + before + .legacy_workflow_snapshot + .as_ref() + .unwrap() + .resolution + .as_deref(), + Some("Original closure text") + ); + if let Some(retained) = retained { + let mut changed_context = retained.source.clone(); + changed_context.insert("source_assignment_id".into(), "new-assignment".into()); + changed_context.insert("assigned_worker_id".into(), "new-recipient".into()); + let actor = backend + .clone() + .with_event_attributes(changed_context.clone()); + assert_eq!( + actor + .set_ticket_tags("closed-legacy".into(), retained.request.clone()) + .unwrap(), + retained.receipt + ); + changed_context.insert("source_worker_id".into(), "other-classifier".into()); + assert!( + backend + .clone() + .with_event_attributes(changed_context) + .set_ticket_tags("closed-legacy".into(), retained.request) + .is_err() + ); + store + .with_conn(|conn| { + assert_eq!(classification_rows(conn), tags_before.clone().unwrap()); + assert!( + conn.execute( + "INSERT INTO typed_ticket_tags VALUES('other','missing',?1)", + [&retained.active_ids[0]] + ) + .is_err() + ); + Ok(()) + }) + .unwrap(); + assert!( + backend + .set_ticket_tags( + "closed-legacy".into(), + ticket::TicketTagSetRequest { + operation_key: "deleted-reference".into(), + expected_tag_ids: retained.active_ids.clone(), + tag_ids: vec![retained.deleted_id] + } + ) + .is_err() + ); + } + // Arbitrary lifecycle-looking tag names cannot alter a reasoned closure, + // digest, assignment or prior immutable case history. + let reasoned = update_test_case( + &backend, + "closed-legacy", + "correct-reason", + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + let tag = backend + .mutate_workspace_tag(ticket::WorkspaceTagMutation { + operation_key: "new-ready".into(), + action: ticket::WorkspaceTagAction::Create { + name: "ready after upgrade".into(), + }, + }) + .unwrap(); + backend + .set_ticket_tags( + "closed-legacy".into(), + ticket::TicketTagSetRequest { + operation_key: "new-attach".into(), + expected_tag_ids: reasoned.tags.iter().map(|tag| tag.id.clone()).collect(), + tag_ids: vec![tag.id], + }, + ) + .unwrap(); + let classified = backend.show("closed-legacy".into()).unwrap(); + assert_eq!(classified.meta, reasoned.meta); + assert_eq!(classified.current_closure, reasoned.current_closure); + assert_eq!(classified.case_history, reasoned.case_history); + assert_eq!( + ticket::ticket_content_digest(&classified), + ticket::ticket_content_digest(&reasoned) + ); + let tag_history: Vec<_> = classified + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .cloned() + .collect(); + let reopened = update_test_case( + &backend, + "closed-legacy", + "reopen", + ticket::TicketCaseAction::Reopen, + ); + assert_eq!(reopened.tags, classified.tags); + assert_eq!( + reopened.case_history[..classified.case_history.len()], + classified.case_history + ); + assert_eq!( + reopened + .events + .iter() + .filter(|event| event.kind == ticket::TicketEventKind::TagsChanged) + .cloned() + .collect::>(), + tag_history + ); + assert!( + store + .get_active_ticket_worker_assignment("case-workspace", "closed-legacy") + .unwrap() + .is_none() + ); +} + +// Frozen cases-only component9 DDL from a558, not a stripped current DB. +const RETAINED_CASES9_SQL: &str = r#" +-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority. +CREATE TABLE typed_ticket_case_metadata ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_case_history ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, operation_key), + UNIQUE (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_legacy_workflow_snapshot ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +"#; + +#[test] +fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history() { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(90); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + // Build a genuine cases-only boundary from retained7 and frozen9 DDL. + // No latest schema is relabelled and no six-state row is called current. + conn.execute_batch(RETAINED_CASES9_SQL).unwrap(); + conn.execute_batch("UPDATE ticket_schema_migrations SET version=9; + INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES('case-workspace','reasoned','reasoned','Existing judgment','closed','task','P2','Original requirements','closed',1); + INSERT INTO typed_ticket_case_metadata VALUES('case-workspace','reasoned','original-closure');").unwrap(); + let judgment = ticket::TicketCaseHistoryEntry { + operation_key: "original-closure".into(), + previous_closure_operation_key: None, + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: "Original reasoned conclusion".into(), + references: vec![ticket::TicketReference { + kind: "commit".into(), + target: "original-source".into(), + }], + author: "original-author".into(), + at: "original-time".into(), + }; + conn.execute("INSERT INTO typed_ticket_case_history VALUES('case-workspace','reasoned',0,'original-closure','original-fingerprint',?1)", + [serde_json::to_string(&judgment).unwrap()]).unwrap(); + assert!(!table_exists(&conn, "workspace_tags").unwrap()); + let history_before = stored_fixture_rows(&conn, "typed_ticket_case_history"); + let metadata_before = stored_fixture_rows(&conn, "typed_ticket_case_metadata"); + let auth_before = fixture_auth_rows(&conn); + drop(conn); + let store = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?,93); + assert_eq!( + conn.query_row( + "SELECT MAX(version) FROM ticket_schema_migrations", + [], + |row| row.get::<_, i64>(0) + )?, + 11 + ); + assert_eq!( + stored_fixture_rows(conn, "typed_ticket_case_history"), + history_before + ); + assert_eq!( + stored_fixture_rows(conn, "typed_ticket_case_metadata"), + metadata_before + ); + assert_eq!(fixture_auth_rows(conn), auth_before); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?, + 0 + ); + ticket::verify_sqlite_ticket_schema(conn).unwrap(); + Ok(()) + }) + .unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + let before = backend.show("reasoned".into()).unwrap(); + assert_eq!(before.case_history, vec![judgment]); + assert_eq!( + before.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Completed + ); + for name in ["ready", "closed"] { + let tag = backend + .mutate_workspace_tag(ticket::WorkspaceTagMutation { + operation_key: format!("create-{name}"), + action: ticket::WorkspaceTagAction::Create { name: name.into() }, + }) + .unwrap(); + let current = backend.show("reasoned".into()).unwrap(); + let mut desired: Vec<_> = current.tags.iter().map(|tag| tag.id.clone()).collect(); + desired.push(tag.id); + backend + .set_ticket_tags( + "reasoned".into(), + ticket::TicketTagSetRequest { + operation_key: format!("attach-{name}"), + expected_tag_ids: current.tags.iter().map(|tag| tag.id.clone()).collect(), + tag_ids: desired, + }, + ) + .unwrap(); + } + let after = backend.show("reasoned".into()).unwrap(); + assert_eq!(after.meta, before.meta); + assert_eq!(after.current_closure, before.current_closure); + assert_eq!(after.case_history, before.case_history); + assert_eq!( + ticket::ticket_content_digest(&after), + ticket::ticket_content_digest(&before) + ); +} diff --git a/crates/workspace-server/src/workdir_create_operations.rs b/crates/workspace-server/src/workdir_create_operations.rs index 29bc8fccc..09fc4082d 100644 --- a/crates/workspace-server/src/workdir_create_operations.rs +++ b/crates/workspace-server/src/workdir_create_operations.rs @@ -759,7 +759,7 @@ mod tests { #[test] fn retry_keeps_resolved_config_and_credential_candidates_after_fallback_moves() { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); futures::executor::block_on(store.upsert_workspace(&WorkspaceRecord { workspace_id: "workspace".to_string(), owner_account_id: "owner-account".to_string(), diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 26839dfd3..c8a80958a 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -939,7 +939,7 @@ mod tests { } async fn seeded_store() -> (SqliteWorkspaceStore, WorkdirRegistryRecord) { - let store = SqliteWorkspaceStore::in_memory().unwrap(); + let store = SqliteWorkspaceStore::in_memory_for_test().unwrap(); store .upsert_account(&AccountRecord { account_id: "account-a".to_string(), @@ -1280,9 +1280,8 @@ mod tests { #[tokio::test] async fn terminal_assignments_release_unfinished_work_guard_but_not_attachment_or_hold() { - for (status, workflow_state) in [("closed", "closed"), ("open", "done")] { - let (store, workdir) = seeded_store().await; - store + let (store, workdir) = seeded_store().await; + store .with_conn(|conn| { conn.execute( "INSERT INTO worker_registry ( @@ -1302,7 +1301,7 @@ mod tests { 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', '', 'inprogress', 1)", + 'task', 'normal', '', 'open', 1)", [], )?; conn.execute( @@ -1321,59 +1320,58 @@ mod tests { Ok(()) }) .unwrap(); - let intent = - workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap(); - let operation = store.reserve_workdir_removal_operation(&intent).unwrap(); - let categories = |guards: Vec| { - guards - .into_iter() - .map(|guard| guard.category) - .collect::>() - }; - assert_eq!( - categories(store.workdir_removal_guards(&operation).unwrap()), - vec!["active_attachment", "unfinished_work", "retention_hold"] - ); - store - .with_conn(|conn| { - conn.execute( - "UPDATE typed_tickets SET status=?1, workflow_state=?2 + let intent = workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap(); + let operation = store.reserve_workdir_removal_operation(&intent).unwrap(); + let categories = |guards: Vec| { + guards + .into_iter() + .map(|guard| guard.category) + .collect::>() + }; + assert_eq!( + categories(store.workdir_removal_guards(&operation).unwrap()), + vec!["active_attachment", "unfinished_work", "retention_hold"] + ); + store + .with_conn(|conn| { + conn.execute( + "UPDATE typed_tickets SET status='closed', workflow_state='closed' WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'", - params![status, workflow_state], - )?; - Ok(()) - }) - .unwrap(); - assert_eq!( - categories(store.workdir_removal_guards(&operation).unwrap()), - vec!["active_attachment", "retention_hold"] - ); - store - .with_conn(|conn| { - conn.execute( - "UPDATE typed_tickets SET status='open', workflow_state='planning' + [], + )?; + Ok(()) + }) + .unwrap(); + assert_eq!( + categories(store.workdir_removal_guards(&operation).unwrap()), + vec!["active_attachment", "retention_hold"] + ); + store + .with_conn(|conn| { + conn.execute( + "UPDATE typed_tickets SET status='open', workflow_state='open' WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'", - [], - )?; - Ok(()) - }) - .unwrap(); - assert_eq!( - categories(store.workdir_removal_guards(&operation).unwrap()), - vec!["active_attachment", "retention_hold"], - "reopening must not restore unfinished work on the retained assignment" - ); - assert!(matches!( - store.commit_workdir_removal_removed(&operation), - Err(Error::WorkdirAttachmentConflict(_)) - )); - assert!( - store - .get_workdir_registry("workspace-a", "workdir-a") - .unwrap() - .is_some() - ); + [], + )?; + Ok(()) + }) + .unwrap(); + assert_eq!( + categories(store.workdir_removal_guards(&operation).unwrap()), + vec!["active_attachment", "retention_hold"], + "reopening must not restore unfinished work on the retained assignment" + ); + assert!(matches!( + store.commit_workdir_removal_removed(&operation), + Err(Error::WorkdirAttachmentConflict(_)) + )); + assert!( store + .get_workdir_registry("workspace-a", "workdir-a") + .unwrap() + .is_some() + ); + store .with_conn(|conn| { conn.execute( "UPDATE worker_registry SET retention_state='normal' @@ -1383,49 +1381,63 @@ mod tests { Ok(()) }) .unwrap(); - assert_eq!( - categories(store.workdir_removal_guards(&operation).unwrap()), - vec!["active_attachment"] - ); - assert!(matches!( - store.commit_workdir_removal_removed(&operation), - Err(Error::WorkdirAttachmentConflict(_)) - )); - store - .with_conn(|conn| { - conn.execute( - "UPDATE worker_workdir_links SET unlinked_at='2' + assert_eq!( + categories(store.workdir_removal_guards(&operation).unwrap()), + vec!["active_attachment"] + ); + assert!(matches!( + store.commit_workdir_removal_removed(&operation), + Err(Error::WorkdirAttachmentConflict(_)) + )); + store + .with_conn(|conn| { + conn.execute( + "UPDATE worker_workdir_links SET unlinked_at='2' WHERE workspace_id='workspace-a' AND workdir_id='workdir-a'", - [], - )?; - Ok(()) - }) - .unwrap(); - assert!(store.workdir_removal_guards(&operation).unwrap().is_empty()); - let completed = store.commit_workdir_removal_removed(&operation).unwrap(); - assert_eq!( - completed.disposition, - Some(WorkdirRemovalDisposition::Removed) - ); - assert!( - store - .get_workdir_registry("workspace-a", "workdir-a") - .unwrap() - .is_none() - ); - let retained: i64 = store - .with_conn(|conn| { - conn.query_row( - "SELECT COUNT(*) FROM ticket_current_worker_assignments + [], + )?; + Ok(()) + }) + .unwrap(); + assert!(store.workdir_removal_guards(&operation).unwrap().is_empty()); + let completed = store.commit_workdir_removal_removed(&operation).unwrap(); + assert_eq!( + completed.disposition, + Some(WorkdirRemovalDisposition::Removed) + ); + assert!( + store + .get_workdir_registry("workspace-a", "workdir-a") + .unwrap() + .is_none() + ); + let retained: i64 = store + .with_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM ticket_current_worker_assignments WHERE workspace_id='workspace-a' AND assignment_id='assignment-a'", - [], - |row| row.get(0), - ) - .map_err(Error::from) - }) - .unwrap(); - assert_eq!(retained, 1); - } + [], + |row| row.get(0), + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!(retained, 1); + let released: i64 = store + .with_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_work_releases + WHERE workspace_id='workspace-a' AND assignment_id='assignment-a'", + [], + |row| row.get(0), + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!( + released, 1, + "reopening and cleanup must preserve the work release" + ); } #[tokio::test] diff --git a/crates/workspace-server/src/worker_projection.rs b/crates/workspace-server/src/worker_projection.rs index ed910de04..ccadb702e 100644 --- a/crates/workspace-server/src/worker_projection.rs +++ b/crates/workspace-server/src/worker_projection.rs @@ -1,3 +1,10 @@ +//! Ordered durable Worker projection, not Runtime execution or Ticket-handling authority. +//! +//! Commit and payload publication share actual bounded DB ownership and the snapshot ordering +//! gate, surviving waiter cancellation. Replacement/shutdown stops producers and drains accepted +//! projection jobs before a new Runtime snapshot can apply; cancelling a consumer alone is not +//! proof that SQL finished. Async resource views reuse the whole stored projection stage rather +//! than acquiring the shared connection on Tokio or reconstructing state from Runtime liveness. use std::collections::HashMap; use std::sync::{Arc, Mutex}; @@ -5,7 +12,7 @@ use chrono::Utc; use protocol::subscription::{ EventSubscriptionSelector, SubscriptionEventPayload, SubscriptionSnapshot, }; -use tokio::sync::broadcast; +use tokio::sync::{Semaphore, broadcast}; use tokio::task::JoinHandle; use crate::runtime_subscription::{ @@ -59,7 +66,14 @@ pub struct WorkerProjectionService { events: broadcast::Sender, ordering: Mutex<()>, attach_gate: tokio::sync::Mutex<()>, - tasks: Mutex>>, + tasks: Mutex>, +} + +struct RuntimeProjectionLifetime { + consumer: Option>, + // The permit belongs to the actual DB closure, not its cancellable async waiter. + // Keep this owner in the map even if attachment initialization is cancelled. + operations: Arc, } impl WorkerProjectionService { @@ -75,17 +89,89 @@ impl WorkerProjectionService { }) } + /// Stops consumers but does not claim that accepted database work has finished. + /// Async shutdown callers must use `shutdown_async` to drain accepted operations. pub fn shutdown(&self) { - for (_, task) in self + for lifetime in self .tasks .lock() .expect("Worker projection task lock poisoned") - .drain() + .values() { - task.abort(); + if let Some(task) = &lifetime.consumer { + task.abort(); + } } } + pub async fn shutdown_async(&self) { + let _attach = self.attach_gate.lock().await; + self.shutdown(); + let runtime_ids = self + .tasks + .lock() + .expect("Worker projection task lock poisoned") + .keys() + .cloned() + .collect::>(); + for runtime_id in runtime_ids { + self.drain_runtime(&runtime_id).await; + } + self.tasks + .lock() + .expect("Worker projection task lock poisoned") + .clear(); + } + + async fn drain_runtime(&self, runtime_id: &str) { + let previous = self + .tasks + .lock() + .expect("Worker projection task lock poisoned") + .get_mut(runtime_id) + .map(|lifetime| (lifetime.consumer.take(), lifetime.operations.clone())); + if let Some((consumer, operations)) = previous { + if let Some(consumer) = consumer { + consumer.abort(); + let _ = consumer.await; + } + // Aborting the waiter cannot abort a queued/running synchronous job. + // Cancellation here leaves the gate in the map for the next drain. + let _drained = operations + .acquire_owned() + .await + .expect("operation gate closed"); + } + } + + // Caller holds attach_gate. Install ownership before any initialization await. + async fn replace_runtime(&self, runtime_id: &str) -> Arc { + self.drain_runtime(runtime_id).await; + let operations = Arc::new(Semaphore::new(1)); + self.tasks + .lock() + .expect("Worker projection task lock poisoned") + .insert( + runtime_id.to_string(), + RuntimeProjectionLifetime { + consumer: None, + operations: operations.clone(), + }, + ); + operations + } + + pub async fn subscribe_ordered_async( + self: &Arc, + limit: usize, + ) -> crate::Result { + let service = self.clone(); + self.store + .db_executor() + .run(move || service.subscribe_ordered(limit)) + .await + } + pub fn subscribe_ordered(&self, limit: usize) -> crate::Result { let _ordering = self .ordering @@ -125,19 +211,10 @@ impl WorkerProjectionService { broker: &RuntimeSubscriptionBroker, runtime_id: &str, ) -> Result<(), RuntimeSubscriptionBrokerError> { - // Serialize replacement and wait for the old consumer to stop before the new - // lifetime can apply its snapshot. Late notifications can therefore never - // cross a Runtime ownership boundary. + // Serialize replacement, stop admission by aborting the consumer, and then + // drain ownership held by the actual SQL jobs before the new snapshot. let _attach = self.attach_gate.lock().await; - let previous = self - .tasks - .lock() - .expect("Worker projection task lock poisoned") - .remove(runtime_id); - if let Some(previous) = previous { - previous.abort(); - let _ = previous.await; - } + let operations = self.replace_runtime(runtime_id).await; let mut subscription = broker.subscribe(runtime_id, EventSubscriptionSelector::RuntimeWorkers)?; @@ -148,12 +225,25 @@ impl WorkerProjectionService { break; }; let initialized = matches!(event, BrokerSubscriptionEvent::Snapshot { .. }); - if let Err(error) = self.apply_broker_event(runtime_id, event) { + if let Err(error) = self + .apply_broker_event_owned_async(runtime_id, event, operations.clone()) + .await + { tracing::warn!( runtime_id, error = %error, "failed to initialize durable Worker projection; restarting from a fresh snapshot" ); + if matches!( + error, + crate::Error::DbOperationOutcomeUnknown + | crate::Error::DbExecutorUnavailable + | crate::Error::DbCapacityExceeded + ) { + // Do not automatically replay a possibly committed mutation or + // spin on executor overload; attachment must be retried explicitly. + return Err(RuntimeSubscriptionBrokerError::Closed); + } subscription.restart()?; continue; } @@ -169,12 +259,27 @@ impl WorkerProjectionService { let Some(service) = service.upgrade() else { return; }; - if let Err(error) = service.apply_broker_event(task_runtime_id.as_str(), event) { + if let Err(error) = service + .apply_broker_event_owned_async( + task_runtime_id.as_str(), + event, + operations.clone(), + ) + .await + { tracing::warn!( runtime_id = %task_runtime_id, error = %error, "failed to update durable Worker projection; restarting from a fresh snapshot" ); + if matches!( + error, + crate::Error::DbOperationOutcomeUnknown + | crate::Error::DbExecutorUnavailable + | crate::Error::DbCapacityExceeded + ) { + return; + } if subscription.restart().is_err() { return; } @@ -184,10 +289,61 @@ impl WorkerProjectionService { self.tasks .lock() .expect("Worker projection task lock poisoned") - .insert(runtime_id, task); + .get_mut(&runtime_id) + .expect("Runtime lifetime installed before initialization") + .consumer = Some(task); Ok(()) } + #[cfg(test)] + async fn seed_observation_async( + self: &Arc, + runtime_id: &str, + worker: &protocol::subscription::SubscriptionWorker, + observed_at: &str, + ) -> crate::Result<()> { + let service = self.clone(); + let runtime_id = runtime_id.to_string(); + let worker = worker.clone(); + let observed_at = observed_at.to_string(); + self.store + .db_executor() + .run(move || service.seed_observation(&runtime_id, &worker, &observed_at)) + .await + } + + pub async fn refresh_async(self: &Arc, worker: &RuntimeWorkerRef) -> crate::Result<()> { + let service = self.clone(); + let worker = worker.clone(); + self.store + .db_executor() + .run(move || service.refresh(&worker)) + .await + } + + async fn apply_broker_event_owned_async( + self: &Arc, + runtime_id: &str, + event: BrokerSubscriptionEvent, + operations: Arc, + ) -> crate::Result<()> { + let operation = operations + .acquire_owned() + .await + .expect("operation gate closed"); + let service = self.clone(); + let runtime_id = runtime_id.to_string(); + self.store + .db_executor() + .run(move || { + // The permit must outlive SQL commit AND ordered publication, even + // when the executor result receiver has been dropped by cancellation. + let _operation = operation; + service.apply_broker_event(&runtime_id, event) + }) + .await + } + pub fn seed_observation( &self, runtime_id: &str, @@ -313,7 +469,11 @@ mod tests { tempfile::TempDir, ) { let temp = tempfile::tempdir().unwrap(); - let store = Arc::new(SqliteWorkspaceStore::open(temp.path().join("workspace.db")).unwrap()); + let store = Arc::new( + SqliteWorkspaceStore::open_for_test(temp.path().join("workspace.db")) + .unwrap() + .with_db_executor(crate::db_executor::DbExecutor::new(Default::default()).unwrap()), + ); store .upsert_account(&AccountRecord { account_id: "owner".to_string(), @@ -355,6 +515,266 @@ mod tests { } } + fn observation( + state: protocol::subscription::SubscriptionWorkerState, + ) -> protocol::subscription::SubscriptionWorker { + protocol::subscription::SubscriptionWorker { + restore_observation_token: None, + worker_id: protocol::subscription::SubscriptionWorkerId::new("worker-a").unwrap(), + runtime_id: Some("runtime-a".to_string()), + resource_key: None, + availability: protocol::subscription::SubscriptionWorkerAvailability::Observed, + worker_state: None, + state, + has_running_internal_workers: false, + workspace_id: Some(WORKSPACE_ID.to_string()), + display_name: None, + profile: None, + job: None, + workdir_attachments: Vec::new(), + } + } + + // Real second-connection contention; the controller releases automatically on + // failure, so a regression that blocks the Tokio thread cannot hang the suite. + struct WriteLock { + release: Option>, + controller: Option>, + } + + impl WriteLock { + fn acquire(path: std::path::PathBuf) -> Self { + let (release, released) = std::sync::mpsc::channel(); + let (locked, ready) = std::sync::mpsc::channel(); + let controller = std::thread::spawn(move || { + let connection = rusqlite::Connection::open(path).unwrap(); + connection.execute_batch("BEGIN IMMEDIATE").unwrap(); + locked.send(()).unwrap(); + let _ = released.recv_timeout(std::time::Duration::from_secs(3)); + connection.execute_batch("ROLLBACK").unwrap(); + }); + ready.recv().unwrap(); + Self { + release: Some(release), + controller: Some(controller), + } + } + + fn release(&mut self) { + if let Some(release) = self.release.take() { + let _ = release.send(()); + } + } + } + + impl Drop for WriteLock { + fn drop(&mut self) { + self.release(); + if let Some(controller) = self.controller.take() { + controller.join().unwrap(); + } + } + } + + async fn wait_for_sql(service: &WorkerProjectionService) { + tokio::time::timeout(std::time::Duration::from_secs(1), async { + // With the second connection holding the write lock, a held ordering + // gate identifies the accepted synchronous mutation, not a fake future. + while service.ordering.try_lock().is_ok() { + tokio::task::yield_now().await; + } + }) + .await + .expect("projection job did not enter synchronous SQL"); + } + + #[tokio::test(flavor = "current_thread")] + async fn sqlite_write_contention_keeps_projection_timer_and_control_live_and_publishes_committed_payload() + { + use protocol::subscription::SubscriptionWorkerState; + let (store, service, temp) = fixture().await; + store + .upsert_worker_registry(&worker("runtime-a", "worker-a")) + .unwrap(); + let mut subscription = service.subscribe_ordered_async(10).await.unwrap(); + assert_eq!(subscription.take_snapshot().len(), 1); + let mut lock = WriteLock::acquire(temp.path().join("workspace.db")); + let publishing_service = service.clone(); + let publication = tokio::spawn(async move { + publishing_service + .seed_observation_async( + "runtime-a", + &observation(SubscriptionWorkerState::Idle), + "2", + ) + .await + }); + wait_for_sql(&service).await; + let started = std::time::Instant::now(); + let (control, controlled) = tokio::sync::oneshot::channel(); + tokio::spawn(async move { + control.send("control progressed").unwrap(); + }); + let (_, control_result) = tokio::join!( + tokio::time::sleep(std::time::Duration::from_millis(20)), + controlled, + ); + assert_eq!(control_result.unwrap(), "control progressed"); + assert!( + started.elapsed() < std::time::Duration::from_secs(1), + "SQLite blocked the current-thread executor" + ); + assert!(matches!( + subscription.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + lock.release(); + publication.await.unwrap().unwrap(); + let committed = subscription.recv().await.unwrap(); + let mut snapshot = service.subscribe_ordered_async(10).await.unwrap(); + let snapshot = snapshot.take_snapshot(); + assert_eq!( + committed.changes, + vec![WorkerCatalogChange::Upsert(snapshot[0].clone())] + ); + assert_eq!( + snapshot[0].observation.as_ref().unwrap().worker.state, + SubscriptionWorkerState::Idle + ); + service + .seed_observation_async( + "runtime-a", + &observation(SubscriptionWorkerState::Running), + "3", + ) + .await + .unwrap(); + // Delayed readers receive the first transaction's payload, not a re-query + // of mutable authority after a later commit. + let WorkerCatalogChange::Upsert(first) = &committed.changes[0] else { + panic!("expected upsert"); + }; + assert_eq!( + first.observation.as_ref().unwrap().worker.state, + SubscriptionWorkerState::Idle + ); + service + .refresh_async(&RuntimeWorkerRef::new("runtime-a", "worker-a")) + .await + .unwrap(); + } + + #[tokio::test(flavor = "current_thread")] + async fn cancelled_runtime_consumer_and_replacement_drain_real_sql_before_new_snapshot() { + use crate::runtime_subscription::BrokerSubscriptionEvent; + use protocol::subscription::SubscriptionWorkerState; + let (store, service, temp) = fixture().await; + store + .upsert_worker_registry(&worker("runtime-a", "worker-a")) + .unwrap(); + let mut subscription = service.subscribe_ordered_async(10).await.unwrap(); + subscription.take_snapshot(); + let operations = service.replace_runtime("runtime-a").await; + let old_operations = operations.clone(); + let mut lock = WriteLock::acquire(temp.path().join("workspace.db")); + let old_service = service.clone(); + let consumer = tokio::spawn(async move { + old_service + .apply_broker_event_owned_async( + "runtime-a", + BrokerSubscriptionEvent::Event { + payload: protocol::subscription::SubscriptionEventPayload::WorkerUpserted { + worker: observation(SubscriptionWorkerState::Running), + }, + }, + operations, + ) + .await + .unwrap(); + }); + service + .tasks + .lock() + .unwrap() + .get_mut("runtime-a") + .unwrap() + .consumer = Some(consumer); + wait_for_sql(&service).await; + let replacing_service = service.clone(); + let mut replacement = tokio::spawn(async move { + let _attach = replacing_service.attach_gate.lock().await; + replacing_service.replace_runtime("runtime-a").await + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), &mut replacement) + .await + .is_err() + ); + replacement.abort(); + assert!(replacement.await.unwrap_err().is_cancelled()); + assert_eq!( + old_operations.available_permits(), + 0, + "cancelled drain lost accepted SQL ownership" + ); + // A second replacement must still observe the old gate after cancellation. + let replacing_service = service.clone(); + let mut replacement = tokio::spawn(async move { + let _attach = replacing_service.attach_gate.lock().await; + let operations = replacing_service.replace_runtime("runtime-a").await; + replacing_service + .apply_broker_event_owned_async( + "runtime-a", + BrokerSubscriptionEvent::Snapshot { + snapshot: protocol::subscription::SubscriptionSnapshot::Workers { + workers: vec![observation(SubscriptionWorkerState::Idle)], + }, + }, + operations, + ) + .await + }); + assert!( + tokio::time::timeout(std::time::Duration::from_millis(20), &mut replacement) + .await + .is_err() + ); + assert!(matches!( + subscription.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + lock.release(); + replacement.await.unwrap().unwrap(); + assert_eq!(old_operations.available_permits(), 1); + let old_commit = subscription.recv().await.unwrap(); + let new_commit = subscription.recv().await.unwrap(); + for (commit, expected) in [ + (old_commit, SubscriptionWorkerState::Running), + (new_commit, SubscriptionWorkerState::Idle), + ] { + let [WorkerCatalogChange::Upsert(record)] = commit.changes.as_slice() else { + panic!("expected one committed upsert"); + }; + assert_eq!(record.observation.as_ref().unwrap().worker.state, expected); + } + let mut snapshot = service.subscribe_ordered_async(10).await.unwrap(); + assert_eq!( + snapshot.take_snapshot()[0] + .observation + .as_ref() + .unwrap() + .worker + .state, + SubscriptionWorkerState::Idle + ); + assert!(matches!( + subscription.try_recv(), + Err(tokio::sync::broadcast::error::TryRecvError::Empty) + )); + service.shutdown_async().await; + assert!(service.tasks.lock().unwrap().is_empty()); + } + #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn snapshot_registration_is_atomic_with_concurrent_publication() { let (store, service, _temp) = fixture().await; diff --git a/crates/workspace-server/src/worker_source.rs b/crates/workspace-server/src/worker_source.rs index f159be54f..fad8c5ea5 100644 --- a/crates/workspace-server/src/worker_source.rs +++ b/crates/workspace-server/src/worker_source.rs @@ -82,10 +82,24 @@ pub enum WorkerMutationSourceProofError { Replay, #[error("source Worker is not a current member of this Workspace Runtime catalog")] WorkerCatalogMembership, + #[error("source proof authority is unavailable")] + Unavailable, #[error("source proof authority failed: {0}")] Authority(String), } +impl From for WorkerMutationSourceProofError { + fn from(error: crate::Error) -> Self { + match error { + crate::Error::DbCapacityExceeded + | crate::Error::DbExecutorUnavailable + | crate::Error::DbOperationOutcomeUnknown + | crate::Error::Sqlite(_) => Self::Unavailable, + other => Self::Authority(other.to_string()), + } + } +} + pub async fn verify_worker_remove_source( api: &WorkspaceApi, proof: PresentedWorkerMutationSourceProof<'_>, @@ -119,7 +133,7 @@ async fn verify_worker_remove_source_with( let trusted = store .get_workspace_runtime_binding(&config.workspace_id, &unverified.iss) .await - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))? + .map_err(WorkerMutationSourceProofError::from)? .filter(|record| record.revoked_at.is_none()) .ok_or(WorkerMutationSourceProofError::RevokedRuntimeTrust)?; let public_key = trusted.public_key.as_str(); @@ -163,15 +177,23 @@ async fn verify_worker_remove_source_with( runtime_id: claims.iss.clone(), worker_id: claims.worker_id.clone(), }; - let member = store - .get_worker_registry(&config.workspace_id, &worker) - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; - if member.is_none() { - return Err(WorkerMutationSourceProofError::WorkerCatalogMembership); - } + let workspace_id = config.workspace_id.clone(); + let catalog_store = Arc::clone(store); store - .require_current_worker_singleton_owner(&config.workspace_id, &worker) - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; + .db_executor() + .run(move || { + let member = catalog_store + .get_worker_registry(&workspace_id, &worker) + .map_err(WorkerMutationSourceProofError::from)?; + if member.is_none() { + return Err(WorkerMutationSourceProofError::WorkerCatalogMembership); + } + catalog_store + .require_current_worker_singleton_owner(&workspace_id, &worker) + .map_err(WorkerMutationSourceProofError::from)?; + Ok(()) + }) + .await?; let consumed_at = chrono::Utc::now().to_rfc3339(); let consumed = store @@ -184,7 +206,7 @@ async fn verify_worker_remove_source_with( &consumed_at, ) .await - .map_err(|error| WorkerMutationSourceProofError::Authority(error.to_string()))?; + .map_err(WorkerMutationSourceProofError::from)?; if !consumed { return Err(WorkerMutationSourceProofError::Replay); } diff --git a/crates/workspace-server/src/worker_source/runtime_request.rs b/crates/workspace-server/src/worker_source/runtime_request.rs index f4daf9c65..ab1a99bef 100644 --- a/crates/workspace-server/src/worker_source/runtime_request.rs +++ b/crates/workspace-server/src/worker_source/runtime_request.rs @@ -5,7 +5,10 @@ //! verification consumes the JTI transactionally and checks Worker registration or creation //! reservations and singleton ownership. One sampled clock serves time checks and JTI records; //! the lower-level verifier owns tolerance and ordering. Diagnostics do not recompute rejection -//! with another clock. +//! with another clock. Trust reads, JTI consumption and catalog checks use bounded DB ownership; +//! capacity/authority failure is operational unavailability, not an invalid proof. Cancellation +//! after admission may still consume the JTI. Successful verification returns the exact binding +//! identity for transaction-local mutation fences, not a newly resolved authentication source. //! //! Rejection reasons expose only safe categories and verification stages. Claim-derived identities //! use domain-separated hashes, omitted on decode failure. request_verified may still precede @@ -14,6 +17,8 @@ //! Proofs, raw claims, keys, bodies, and store error text are not retained. Identity hashes are //! not encryption and do not make guessable IDs secret. +use std::sync::Arc; + use worker_runtime::auth::{ RuntimeAuthError, RuntimeRequestClaim, RuntimeRequestSourceClaims, RuntimeRequestSourceExpectation, decode_runtime_request_source_claims, @@ -28,6 +33,9 @@ use crate::store::ControlPlaneStore; pub struct VerifiedRuntimeRequestSource { pub runtime_id: String, pub worker_id: Option, + /// The binding actually used for signature verification. Mutation boundaries + /// compare this identity with current trust inside their write transaction. + pub authentication_binding_id: String, /// Existing proof JTI correlation, retained only as a fixed-length diagnostic fingerprint. pub token_id_hash: Option, } @@ -46,9 +54,21 @@ pub struct RuntimeRequestProofError { pub(crate) exp: Option, pub(crate) now: Option, pub(crate) time_delta_seconds: Option, + /// Operational unavailability is not a proof or principal rejection. + pub(crate) availability: bool, } impl RuntimeRequestProofError { + pub fn is_availability_error(&self) -> bool { + self.availability + } + + fn unavailable(mut self, reason: &'static str) -> Self { + self.reason = reason; + self.availability = true; + self + } + fn undecoded() -> Self { Self { reason: "invalid", @@ -60,6 +80,7 @@ impl RuntimeRequestProofError { exp: None, now: None, time_delta_seconds: None, + availability: false, } } @@ -119,6 +140,18 @@ impl RuntimeRequestProofError { } } +impl From for RuntimeRequestProofError { + fn from(error: crate::Error) -> Self { + let reason = match error { + crate::Error::DbCapacityExceeded => "db_capacity_exceeded", + crate::Error::DbExecutorUnavailable => "db_executor_unavailable", + crate::Error::DbOperationOutcomeUnknown => "db_operation_outcome_unknown", + _ => "database_authority_unavailable", + }; + Self::undecoded().unavailable(reason) + } +} + /// Domain-separated fixed-length fingerprints, not raw request-derived IDs. /// Operators can correlate a known ID by applying this same function. pub fn diagnostic_id_hash(id: &str) -> String { @@ -143,7 +176,7 @@ pub async fn verify_runtime_request_source_proof( body_digest: &str, ) -> Result { verify_runtime_request_source_proof_with_store( - api.store.as_ref(), + Arc::clone(&api.store), &api.config, proof, workspace_id, @@ -157,7 +190,7 @@ pub async fn verify_runtime_request_source_proof( #[allow(clippy::too_many_arguments)] pub async fn verify_runtime_request_source_proof_with_store( - store: &dyn ControlPlaneStore, + store: Arc, config: &ServerConfig, proof: &str, workspace_id: &str, @@ -182,7 +215,7 @@ pub async fn verify_runtime_request_source_proof_with_store( #[allow(clippy::too_many_arguments)] pub(crate) async fn verify_runtime_request_source_proof_with_clock( - store: &dyn ControlPlaneStore, + store: Arc, config: &ServerConfig, proof: &str, workspace_id: &str, @@ -195,15 +228,18 @@ pub(crate) async fn verify_runtime_request_source_proof_with_clock( let unverified = decode_runtime_request_source_claims(proof) .map_err(|error| RuntimeRequestProofError::undecoded().auth_error(error))?; let diagnostic = RuntimeRequestProofError::decoded(&unverified); - let audience = remote_audience(config, workspace_id) - .map_err(|_| diagnostic.clone().reason("backend_audience_unavailable"))?; + let audience = remote_audience(config, workspace_id).map_err(|_| { + diagnostic + .clone() + .unavailable("backend_audience_unavailable") + })?; let trusted = store .get_workspace_runtime_binding(workspace_id, &unverified.iss) .await .map_err(|_| { diagnostic .clone() - .reason("runtime_trust_authority_unavailable") + .unavailable("runtime_trust_authority_unavailable") })? .filter(|record| record.revoked_at.is_none()) .ok_or_else(|| { @@ -251,7 +287,11 @@ pub(crate) async fn verify_runtime_request_source_proof_with_clock( &consumed_at, ) .await - .map_err(|_| diagnostic.clone().reason("replay_authority_unavailable"))? + .map_err(|_| { + diagnostic + .clone() + .unavailable("replay_authority_unavailable") + })? { return Err(diagnostic.reason("replay")); } @@ -260,34 +300,351 @@ pub(crate) async fn verify_runtime_request_source_proof_with_clock( runtime_id: claims.iss.clone(), worker_id: worker_id.to_owned(), }; - let member = store - .get_worker_registry(workspace_id, &worker) - .map_err(|_| { - diagnostic - .clone() - .reason("worker_catalog_authority_unavailable") - })?; - let reserved = store - .has_active_worker_create_reservation(workspace_id, &worker) - .map_err(|_| { - diagnostic - .clone() - .reason("worker_reservation_authority_unavailable") - })?; - if member.is_none() && !reserved { - return Err(diagnostic.reason("worker_catalog_membership")); - } - store - .require_current_worker_singleton_owner(workspace_id, &worker) - .map_err(|_| { - diagnostic - .clone() - .reason("worker_singleton_authority_denied") + let workspace_id = workspace_id.to_owned(); + let executor = store.db_executor(); + let stage_diagnostic = diagnostic.clone(); + // Only synchronous catalog authority lives in this closure. The trust + // and JTI async wrappers above already offload through the executor. + executor + .run(move || { + let member = store + .get_worker_registry(&workspace_id, &worker) + .map_err(|_| { + stage_diagnostic + .clone() + .unavailable("worker_catalog_authority_unavailable") + })?; + let reserved = store + .has_active_worker_create_reservation(&workspace_id, &worker) + .map_err(|_| { + stage_diagnostic + .clone() + .unavailable("worker_reservation_authority_unavailable") + })?; + if member.is_none() && !reserved { + return Err(stage_diagnostic.reason("worker_catalog_membership")); + } + store + .require_current_worker_singleton_owner(&workspace_id, &worker) + .map_err(|error| { + if matches!( + error, + crate::Error::DbCapacityExceeded + | crate::Error::DbExecutorUnavailable + | crate::Error::DbOperationOutcomeUnknown + | crate::Error::Sqlite(_) + ) { + stage_diagnostic + .clone() + .unavailable("worker_singleton_authority_unavailable") + } else { + stage_diagnostic + .clone() + .reason("worker_singleton_authority_denied") + } + })?; + Ok(()) + }) + .await + .map_err(|error: RuntimeRequestProofError| { + if error.verification == "unverified" && error.is_availability_error() { + diagnostic.clone().unavailable(error.reason) + } else { + error + } })?; } Ok(VerifiedRuntimeRequestSource { token_id_hash: Some(diagnostic_id_hash(&claims.jti)), runtime_id: claims.iss, worker_id: claims.worker_id, + authentication_binding_id: trusted.binding_id, }) } + +#[cfg(test)] +mod tests { + use super::*; + use crate::store::{ + AccountRecord, SqliteWorkspaceStore, WorkspaceRecord, WorkspaceRuntimeAuthenticationMode, + WorkspaceRuntimeBinding, + }; + use std::time::Duration; + use worker_runtime::auth::{ + BACKEND_RESOURCE_FETCH_PERMISSION, RuntimeIdentityMaterial, RuntimeRequestSourceSigner, + request_body_digest, + }; + + const WORKSPACE: &str = "019d0000-0000-7000-8000-0000000000aa"; + const NOW: i64 = 1_700_000_000; + const PATH: &str = "/api/runtime/v1/resources/fetch"; + + // rusqlite's busy handler accepts a function pointer, not a closure. Each + // fixture owns a dedicated single-worker executor, so this thread-local + // handshake is isolated from all parallel tests and product connections. + thread_local! { + static SQLITE_BUSY_HANDSHAKE: std::cell::RefCell, + std::sync::mpsc::Receiver<()>, + )>> = const { std::cell::RefCell::new(None) }; + } + + fn acknowledge_sqlite_contention(_attempt: i32) -> bool { + SQLITE_BUSY_HANDSHAKE.with(|slot| { + let Some((blocked, retry)) = slot.borrow_mut().take() else { + return false; + }; + let _ = blocked.send(()); + // The test releases SQLite's writer lock before allowing retry. + // Dropping the sender on panic also releases this worker promptly. + retry.recv_timeout(Duration::from_secs(3)).is_ok() + }) + } + + async fn fixture( + root: &std::path::Path, + ) -> (Arc, ServerConfig, String, String) { + let db_path = root.join("auth.db"); + let executor = crate::db_executor::DbExecutor::new(crate::db_executor::DbExecutorLimits { + workers: std::num::NonZeroUsize::new(1).unwrap(), + queued: std::num::NonZeroUsize::new(1).unwrap(), + }) + .unwrap(); + let store = Arc::new( + SqliteWorkspaceStore::open_for_test(&db_path) + .unwrap() + .with_db_executor(executor), + ); + store + .upsert_account(&AccountRecord { + account_id: "account-auth".into(), + kind: "user".into(), + handle: "auth".into(), + display_name: "Auth".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .unwrap(); + let workspace = WorkspaceRecord { + workspace_id: WORKSPACE.into(), + owner_account_id: "account-auth".into(), + display_name: "Auth".into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }; + store.upsert_workspace(&workspace).await.unwrap(); + let config = ServerConfig { + workspace_id: WORKSPACE.into(), + workspace_display_name: "Auth".into(), + workspace_created_at: "1".into(), + workspace_execution_root: root.to_owned(), + database_path: db_path, + data_root: root.to_owned(), + embedded_runtime_store_root: root.join("runtime"), + static_assets_dir: None, + auth: crate::server::AuthConfig::Passkey { + rp_id: "localhost".into(), + origin: "http://localhost".into(), + public_base_url: "http://localhost".into(), + cookie_name: "session".into(), + }, + max_records: 10, + repositories: vec![], + runtime_event_sources: vec![], + remote_runtime_sources: vec![], + backend_base_url: Some("server-auth".into()), + }; + let identity = RuntimeIdentityMaterial::generate("runtime-auth").unwrap(); + store + .upsert_workspace_runtime_binding_record( + WorkspaceRuntimeBinding { + workspace_id: WORKSPACE.into(), + runtime_id: "runtime-auth".into(), + display_name: "Auth".into(), + base_url: "http://127.0.0.1:1".into(), + public_key: identity.public_key.clone(), + public_key_fingerprint: String::new(), + binding_id: String::new(), + authentication_mode: WorkspaceRuntimeAuthenticationMode::LegacyServerIssuer, + created_at: "1".into(), + updated_at: "1".into(), + revoked_at: None, + }, + false, + ) + .await + .unwrap(); + let binding = ControlPlaneStore::get_workspace_runtime_binding( + store.as_ref(), + WORKSPACE, + "runtime-auth", + ) + .await + .unwrap() + .unwrap(); + let proof = RuntimeRequestSourceSigner::from_identity(&identity) + .issue( + "server-auth", + WORKSPACE, + None, + BACKEND_RESOURCE_FETCH_PERMISSION, + "POST", + PATH, + b"{}", + NOW, + 30, + ) + .unwrap(); + (store, config, proof, binding.binding_id) + } + + #[tokio::test(flavor = "current_thread")] + async fn sqlite_blocked_jti_keeps_timer_and_control_tasks_responsive_and_rejects_replay() { + let root = tempfile::tempdir().unwrap(); + let (store, config, proof, binding_id) = fixture(root.path()).await; + // A real independent SQLite writer prevents JTI insertion while trust + // reads remain possible. Rollback on Drop also releases this on panic. + let mut writer = rusqlite::Connection::open(&config.database_path).unwrap(); + let lock = writer + .transaction_with_behavior(rusqlite::TransactionBehavior::Immediate) + .unwrap(); + let (blocked, contention_reached) = tokio::sync::oneshot::channel(); + let (retry, retry_rx) = std::sync::mpsc::channel(); + let setup_store = store.clone(); + store + .db_executor() + .run(move || -> crate::Result<()> { + SQLITE_BUSY_HANDSHAKE.with(|slot| *slot.borrow_mut() = Some((blocked, retry_rx))); + setup_store.with_conn(|conn| { + conn.busy_handler(Some(acknowledge_sqlite_contention))?; + Ok(()) + }) + }) + .await + .unwrap(); + let request_store = store.clone(); + let request_config = config.clone(); + let request_proof = proof.clone(); + let task = tokio::spawn(async move { + verify_runtime_request_source_proof_with_clock( + request_store, + &request_config, + &request_proof, + WORKSPACE, + BACKEND_RESOURCE_FETCH_PERMISSION, + "POST", + PATH, + &request_body_digest(b"{}"), + &|| NOW, + ) + .await + }); + tokio::time::timeout(Duration::from_secs(1), contention_reached) + .await + .expect( + "SQLite JTI persistence must report real writer contention without blocking Tokio", + ) + .unwrap(); + // The busy callback acknowledges an actual SQLite write blocked by the + // independent writer, then holds retry until explicitly released. + assert!( + !task.is_finished(), + "JTI must remain pending while SQLite writer is locked" + ); + let control = tokio::spawn(async { "control-responsive" }); + assert_eq!( + tokio::time::timeout(Duration::from_secs(1), control) + .await + .unwrap() + .unwrap(), + "control-responsive" + ); + // This timer is the behavior being tested, not a guessed wait for SQL. + tokio::time::timeout( + Duration::from_secs(1), + tokio::time::sleep(Duration::from_millis(1)), + ) + .await + .unwrap(); + assert!( + !task.is_finished(), + "timer must advance while JTI remains blocked" + ); + lock.rollback().unwrap(); + retry.send(()).unwrap(); + let source = tokio::time::timeout(Duration::from_secs(3), task) + .await + .unwrap() + .unwrap() + .unwrap(); + assert_eq!(source.runtime_id, "runtime-auth"); + assert_eq!(source.authentication_binding_id, binding_id); + let replay = verify_runtime_request_source_proof_with_clock( + store, + &config, + &proof, + WORKSPACE, + BACKEND_RESOURCE_FETCH_PERMISSION, + "POST", + PATH, + &request_body_digest(b"{}"), + &|| NOW, + ) + .await + .unwrap_err(); + assert_eq!(replay.reason, "replay"); + assert!(!replay.is_availability_error()); + } + + #[tokio::test] + async fn saturated_database_is_availability_failure_not_invalid_proof() { + let root = tempfile::tempdir().unwrap(); + let (store, config, proof, _) = fixture(root.path()).await; + // Fill the isolated executor with a blocked job and queued jobs. No + // scheduling sleeps: the worker explicitly acknowledges its start. + let executor = store.db_executor(); + let (started, started_rx) = tokio::sync::oneshot::channel(); + let (release, release_rx) = std::sync::mpsc::channel(); + let blocker_executor = executor.clone(); + let blocker = tokio::spawn(async move { + blocker_executor + .run(move || -> crate::Result<()> { + let _ = started.send(()); + release_rx.recv().unwrap(); + Ok(()) + }) + .await + }); + tokio::time::timeout(Duration::from_secs(1), started_rx) + .await + .unwrap() + .unwrap(); + let mut queued = Box::pin(executor.run(|| -> crate::Result<()> { Ok(()) })); + assert!(futures::poll!(queued.as_mut()).is_pending()); + let outcome = tokio::time::timeout( + Duration::from_secs(1), + verify_runtime_request_source_proof_with_clock( + store, + &config, + &proof, + WORKSPACE, + BACKEND_RESOURCE_FETCH_PERMISSION, + "POST", + PATH, + &request_body_digest(b"{}"), + &|| NOW, + ), + ) + .await; + // Release before assertions so a failed contract never strands a worker. + release.send(()).unwrap(); + blocker.await.unwrap().unwrap(); + queued.await.unwrap(); + let error = outcome + .expect("capacity rejection must not wait for queued SQL") + .unwrap_err(); + assert_eq!(error.reason, "runtime_trust_authority_unavailable"); + assert!(error.is_availability_error()); + } +} diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index 7e4aede96..49f789038 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -302,7 +302,7 @@ mod tests { #[tokio::test] async fn create_is_atomic_and_exact_retries_converge() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap()); let service = WorkspaceCatalogService::new( store.clone(), Arc::new( @@ -356,7 +356,7 @@ mod tests { #[test] fn create_rejects_noncanonical_repository_keys_without_persisting_a_workspace() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap()); let service = WorkspaceCatalogService::new( store.clone(), Arc::new( @@ -395,7 +395,7 @@ mod tests { for with_repository in [false, true] { let temp = tempfile::tempdir().unwrap(); let database_path = temp.path().join("server.db"); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database_path).unwrap()); let owner_account_id = owner_account(store.as_ref()); let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); let service = WorkspaceCatalogService::new( @@ -435,7 +435,7 @@ mod tests { drop(service); drop(store); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database_path).unwrap()); let restarted = WorkspaceCatalogService::new(store.clone(), materials); let created = restarted.create(request, owner_account_id).unwrap(); let identity = store @@ -452,7 +452,7 @@ mod tests { for with_repository in [false, true] { let temp = tempfile::tempdir().unwrap(); let database_path = temp.path().join("server.db"); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database_path).unwrap()); let owner_account_id = owner_account(store.as_ref()); let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); let service = WorkspaceCatalogService::new(store.clone(), materials.clone()); @@ -503,7 +503,7 @@ mod tests { drop(service); drop(store); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database_path).unwrap()); let restarted = WorkspaceCatalogService::new(store.clone(), materials); let created = restarted.create(request, owner_account_id).unwrap(); let identity = store @@ -517,7 +517,7 @@ mod tests { #[tokio::test] async fn idempotency_key_reuse_with_different_payload_is_rejected() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap()); let owner_account_id = owner_account(store.as_ref()); let service = WorkspaceCatalogService::new( store, @@ -549,7 +549,7 @@ mod tests { #[test] fn repository_free_create_initializes_authority_and_concurrent_retries_converge() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap()); let owner = owner_account(store.as_ref()); let service = WorkspaceCatalogService::new( store.clone(), @@ -628,7 +628,7 @@ mod tests { #[test] fn replay_reads_current_repository_at_initial_key_and_never_recreates_it() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap()); let owner = owner_account(store.as_ref()); let service = WorkspaceCatalogService::new( store.clone(), @@ -690,13 +690,13 @@ mod tests { repository: None, }; let (owner, created) = { - let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database).unwrap()); let owner = owner_account(store.as_ref()); let service = WorkspaceCatalogService::new(store, materials.clone()); let created = service.create(request.clone(), owner.clone()).unwrap(); (owner, created) }; - let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database).unwrap()); let reopened = WorkspaceCatalogService::new(store.clone(), materials); let replay = reopened.create(request, owner).unwrap(); assert_eq!(replay.workspace, created.workspace); @@ -756,7 +756,7 @@ mod tests { #[test] fn create_rejects_non_user_account_owners() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap()); store .upsert_account(&AccountRecord { account_id: "organization-owner".to_string(), @@ -794,7 +794,7 @@ mod tests { #[test] fn catalog_list_is_scoped_to_the_required_owner_account() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap()); let owner_a = owner_account(store.as_ref()); let owner_b = "account-owner-b".to_string(); store @@ -862,7 +862,7 @@ mod tests { #[test] fn remote_repository_creation_persists_typed_source_without_auth_metadata() { - let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let store = Arc::new(SqliteWorkspaceStore::in_memory_for_test().unwrap()); let owner_account_id = owner_account(store.as_ref()); let service = WorkspaceCatalogService::new( store.clone(), diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 5dcf9d009..5003d4153 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -45,6 +45,11 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "repository_ssh_host_trust_keys", "repository_ssh_host_trusts", "server_secret_objects", + "ticket_assignment_delivery_acknowledgements", + "ticket_assignment_delivery_requests", + "ticket_assignment_deliveries", + "ticket_assignment_delivery_origins", + "ticket_assignment_operation_fences", "ticket_assignment_operations", "ticket_assignment_work_releases", "ticket_assignment_ticket_tombstones", @@ -52,6 +57,11 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "ticket_current_worker_assignments", "ticket_worker_assignment_events", "ticket_worker_assignments", + "typed_ticket_assignment_tracking_history", + "typed_ticket_assignment_tracking_operations", + "typed_ticket_case_history", + "typed_ticket_case_metadata", + "typed_ticket_legacy_workflow_snapshot", "typed_ticket_artifacts", "typed_ticket_event_attributes", "typed_ticket_event_references", @@ -62,6 +72,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "typed_ticket_relations", "typed_ticket_risk_flags", "typed_ticket_targets", + "typed_ticket_tags", "typed_tickets", "workdir_create_credential_candidates", "workdir_create_credential_retentions", @@ -93,6 +104,8 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "workspace_config_tree_history", "workspace_config_trees", "workspace_create_operations", + "workspace_tag_operations", + "workspace_tags", "workspace_memory_documents", "workspace_memory_settings", "workspace_resource_key_counters", @@ -223,7 +236,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { "Workspace deletion request exceeds bounded field limits".to_string(), )); } - self.with_transaction(|tx| { + self.with_immediate_transaction(|tx| { if let Some(existing) = read_operation(tx, &request.operation_id)? { if existing.actor_account_id != actor_account_id { return Err(Error::WorkspacePermissionDenied( @@ -356,7 +369,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { child_operation_id: &str, ) -> Result { validate_operation_id(child_operation_id)?; - self.with_transaction(|tx| { + self.with_immediate_transaction(|tx| { let operation = read_operation(tx, operation_id)? .ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))? .response; @@ -398,7 +411,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { failure_category: Option<&str>, ) -> Result { validate_operation_projection(child_operation_ids, blockers)?; - self.with_transaction(|tx| { + self.with_immediate_transaction(|tx| { let current = read_operation(tx, operation_id)? .ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))? .response; @@ -449,7 +462,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { &self, operation_id: &str, ) -> Result { - self.with_transaction(|tx| { + self.with_immediate_transaction(|tx| { tx.execute_batch("PRAGMA defer_foreign_keys = ON;")?; let operation = read_operation(tx, operation_id)? .ok_or_else(|| Error::InvalidInput("Workspace deletion operation".to_string()))?; @@ -458,6 +471,37 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { } let workspace_id = operation.response.workspace_id.clone(); + // Ticket case/assignment judgments and their operation receipts are + // immutable under ordinary domain operations, including Ticket + // deletion and FK cascades. Only this owner-authorized whole-Workspace + // purge may discard them. + // SQLite transactional DDL holds the writer lock: no concurrent writer + // can observe the suspended guards, and any error rolls back both + // data and schema. Restore the exact schema-owned definitions before + // commit; update guards remain installed throughout. + let mut ticket_delete_guards = Vec::new(); + for (table, trigger) in [ + ("ticket_worker_assignments", "typed_ticket_assignment_identity_no_delete"), + ("ticket_worker_assignment_events", "typed_ticket_assignment_events_no_delete"), + ("ticket_assignment_operations", "typed_ticket_assignment_receipt_no_delete"), + ("ticket_assignment_deliveries", "ticket_assignment_delivery_payload_no_delete"), + ("ticket_assignment_delivery_requests", "ticket_assignment_delivery_request_no_delete"), + ("ticket_assignment_delivery_acknowledgements", "ticket_assignment_delivery_ack_no_delete"), + ("ticket_assignment_delivery_origins", "ticket_assignment_delivery_origin_no_delete"), + ("ticket_assignment_operation_fences", "ticket_assignment_operation_fence_no_delete"), + ("typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_history_no_delete"), + ("typed_ticket_assignment_tracking_operations", "typed_ticket_assignment_tracking_operations_no_delete"), + ("typed_ticket_case_history", "typed_ticket_case_history_no_delete"), + ("typed_ticket_legacy_workflow_snapshot", "typed_ticket_legacy_workflow_no_delete"), + ] { + let definition: String = tx.query_row( + "SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?1 AND tbl_name=?2", + params![trigger, table], + |row| row.get(0), + )?; + tx.execute_batch(&format!("DROP TRIGGER \"{trigger}\";"))?; + ticket_delete_guards.push(definition); + } for table in WORKSPACE_DELETION_PURGE_TABLES { tx.execute( &format!("DELETE FROM \"{table}\" WHERE workspace_id = ?1"), @@ -473,6 +517,9 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { "Workspace disappeared before deletion finalized".to_string(), )); } + for definition in ticket_delete_guards { + tx.execute_batch(&definition)?; + } let now = Utc::now().to_rfc3339(); tx.execute( "UPDATE workspace_deletion_operations @@ -875,8 +922,10 @@ mod tests { use tempfile::tempdir; fn setup() -> (SqliteWorkspaceStore, String, String) { - let dir = tempdir().expect("tempdir"); - let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).expect("store"); + seed_workspaces(SqliteWorkspaceStore::in_memory_for_test().expect("store")) + } + + fn seed_workspaces(store: SqliteWorkspaceStore) -> (SqliteWorkspaceStore, String, String) { store.with_conn(|conn| { let now = Utc::now().to_rfc3339(); conn.execute( @@ -932,6 +981,252 @@ mod tests { .expect("purge inventory"); } + #[test] + fn workspace_purge_preserves_other_cases_and_restores_immutability_after_commit_or_rollback() { + use ticket::TicketBackend; + + for inject_failure in [false, true] { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("server.db"); + let (store, owner, workspace_id) = + seed_workspaces(SqliteWorkspaceStore::open_for_test(&path).expect("store")); + for workspace in ["workspace-a", "workspace-b"] { + let backend = ticket::SqliteTicketBackend::open_verified(&path, workspace).unwrap(); + let initial = backend + .create_with_snapshot(ticket::NewTicket::new("Case evidence")) + .unwrap() + .1; + let assignment_id = format!("delegation-{workspace}"); + store + .set_current_ticket_role_assignment( + &crate::store::TicketRoleAssignmentRecord { + workspace_id: workspace.into(), + ticket_id: initial.meta.id.clone(), + assignment_id: assignment_id.clone(), + role: crate::store::TicketAssignmentRole::Orchestrator, + principal: crate::store::TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".into(), + }, + assigned_by: "owner".into(), + assigned_at: "2026-01-01T00:00:00Z".into(), + }, + None, + "assigned", + "assign", + false, + ) + .unwrap(); + // Seed domain handling evidence under a transaction. This fixture + // exercises owner purge, not singleton authentication or delivery. + store + .with_immediate_transaction(|tx| { + ticket::sqlite_update_ticket_assignment_tracking_in_transaction( + tx, + workspace, + &initial.meta.id, + &ticket::TicketAssignmentTrackingUpdate { + assignment_id: assignment_id.clone(), + expected_last_event_id: "assigned".into(), + operation_key: "reported".into(), + tracking: ticket::TicketAssignmentTracking { + status: ticket::TicketAssignmentTrackingStatus::Done, + waiting_reason: None, + progress: None, + next_action: None, + result: Some("Returned result".into()), + }, + }, + &ticket::TicketAssignmentTrackingSourceContext { + actor: "orchestrator".into(), + runtime_id: "fixture-runtime".into(), + worker_id: "fixture-worker".into(), + agent_key: "workspace-orchestrator".into(), + authentication_binding_id: "fixture-binding".into(), + }, + )?; + Ok(()) + }) + .unwrap(); + backend + .update_case( + &initial.meta.id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&initial), + expected_state: initial.meta.workflow_state, + expected_closure_operation_key: initial.closure_operation_key, + operation_key: "close".into(), + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: "Requested result satisfied".into(), + references: vec![], + author: Some("owner".into()), + }, + ) + .unwrap(); + // Seed only the opaque retained snapshot for the deletion boundary; + // migration and legacy phase interpretation are tested by the store. + let snapshot = ticket::TicketLegacyWorkflowSnapshot { + workflow_state: "closed".into(), + workflow_state_explicit: true, + status: "closed".into(), + queued_by: None, + queued_at: None, + resolution: None, + }; + store + .with_conn(|conn| { + conn.execute( + "INSERT INTO typed_ticket_legacy_workflow_snapshot + (workspace_id,ticket_id,snapshot_json) VALUES (?1,?2,?3)", + params![ + workspace, + initial.meta.id, + serde_json::to_string(&snapshot).unwrap() + ], + )?; + Ok(()) + }) + .unwrap(); + } + let assert_immutable = |workspace: &str| { + store.with_conn(|conn| { + for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_worker_assignment_events", "typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_operations", "ticket_assignment_operations"] { + assert!(conn.execute(&format!("DELETE FROM {table} WHERE workspace_id=?1"), [workspace]).is_err()); + assert!(conn.execute(&format!("UPDATE {table} SET ticket_id=ticket_id WHERE workspace_id=?1"), [workspace]).is_err()); + } + assert!(conn.execute("DELETE FROM typed_tickets WHERE workspace_id=?1", [workspace]).is_err(), + "ordinary Ticket deletion must not cascade away case evidence"); + Ok(()) + }).unwrap(); + }; + assert_immutable("workspace-a"); + assert_immutable("workspace-b"); + let preflight = store + .workspace_deletion_preflight(&owner, &workspace_id) + .unwrap(); + let operation = store + .reserve_workspace_deletion( + &owner, + &workspace_id, + &WorkspaceDeletionRequest { + operation_id: "delete-cases".into(), + expected_workspace_updated_at: preflight.expected_workspace_updated_at, + confirmation: "Alpha".into(), + }, + ) + .unwrap() + .operation; + let tables = [ + "typed_ticket_case_history", + "typed_ticket_case_metadata", + "typed_ticket_legacy_workflow_snapshot", + "ticket_worker_assignments", + "ticket_worker_assignment_events", + "ticket_assignment_operations", + "typed_ticket_assignment_tracking_history", + "typed_ticket_assignment_tracking_operations", + ]; + let other_before: Vec> = store + .with_conn(|conn| { + tables + .iter() + .map(|table| { + conn.query_row( + &format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"), + [], + |row| row.get(row.as_ref().column_count() - 1), + ) + .map_err(Into::into) + }) + .collect() + }) + .unwrap(); + if inject_failure { + store + .with_conn(|conn| { + conn.execute_batch( + "CREATE TRIGGER reject_workspace_purge BEFORE DELETE ON workspaces + BEGIN SELECT RAISE(ABORT, 'injected purge failure'); END;", + )?; + Ok(()) + }) + .unwrap(); + assert!( + store + .finalize_workspace_deletion(&operation.operation_id) + .is_err() + ); + assert_immutable("workspace-a"); + assert_immutable("workspace-b"); + store + .with_conn(|conn| { + for table in tables { + let count: i64 = conn.query_row( + &format!( + "SELECT COUNT(*) FROM {table} WHERE workspace_id='workspace-a'" + ), + [], + |row| row.get(0), + )?; + let expected = if table == "typed_ticket_assignment_tracking_history" { + 2 + } else { + 1 + }; + assert_eq!(count, expected, "rollback must restore {table}"); + } + conn.execute_batch("DROP TRIGGER reject_workspace_purge;")?; + Ok(()) + }) + .unwrap(); + assert_ne!( + store + .workspace_deletion_operation_for_recovery(&operation.operation_id) + .unwrap() + .unwrap() + .state, + WorkspaceDeletionState::Succeeded + ); + } + let completed = store + .finalize_workspace_deletion(&operation.operation_id) + .unwrap(); + assert_eq!(completed.state, WorkspaceDeletionState::Succeeded); + assert_eq!( + store + .finalize_workspace_deletion(&operation.operation_id) + .unwrap(), + completed + ); + assert_immutable("workspace-b"); + store + .with_conn(|conn| { + for (table, before) in tables.iter().zip(&other_before) { + let count: i64 = conn.query_row( + &format!( + "SELECT COUNT(*) FROM {table} WHERE workspace_id='workspace-a'" + ), + [], + |row| row.get(0), + )?; + assert_eq!(count, 0, "purge must remove {table}"); + let after: Option = conn.query_row( + &format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"), + [], + |row| row.get(row.as_ref().column_count() - 1), + )?; + assert_eq!( + &after, before, + "other Workspace {table} evidence must remain unchanged" + ); + } + Ok(()) + }) + .unwrap(); + } + } + #[test] fn deletion_is_idempotent_and_removes_workspace_scoped_rows() { let (store, owner, workspace_id) = setup(); diff --git a/crates/workspace-server/src/workspace_drive_host.rs b/crates/workspace-server/src/workspace_drive_host.rs index 84d97f3b7..c072b7dd6 100644 --- a/crates/workspace-server/src/workspace_drive_host.rs +++ b/crates/workspace-server/src/workspace_drive_host.rs @@ -63,10 +63,13 @@ pub(crate) struct WorkspaceDriveHost { impl WorkspaceDriveHost { pub(crate) async fn new(storage: &WorkspaceFeatureStorage) -> Result { let storage = storage.clone(); - let registration = tokio::task::spawn_blocking(move || Drive::register(&storage)) - .await - .map_err(|error| Error::Store(format!("Drive registration task failed: {error}")))? - .map_err(|error| Error::Store(format!("failed to register Drive storage: {error}")))?; + let registration = crate::db_executor::DbExecutor::shared() + .run(move || { + Drive::register(&storage).map_err(|error| { + Error::Store(format!("failed to register Drive storage: {error}")) + }) + }) + .await?; Ok(Self { registration, purged: AtomicBool::new(false), @@ -85,21 +88,21 @@ impl WorkspaceDriveHost { } let storage = storage.clone(); let registration = self.registration.clone(); - let drive = tokio::task::spawn_blocking(move || { - reject_symlink_ancestors(&root)?; - // Domain IMMEDIATE transactions lock the attached server authority - // as well as Drive metadata. Deletion reservation and cached-handle - // operations therefore serialize with other server DB writes. - Drive::open_with_workspace_authority( - &storage, - ®istration, - &root, - &authority_database, - ) - .map_err(Error::from) - }) - .await - .map_err(|error| Error::Store(format!("Drive open task failed: {error}")))??; + let drive = crate::db_executor::DbExecutor::shared() + .run(move || { + reject_symlink_ancestors(&root)?; + // Domain IMMEDIATE transactions lock the attached server authority + // as well as Drive metadata. Deletion reservation and cached-handle + // operations therefore serialize with other server DB writes. + Drive::open_with_workspace_authority( + &storage, + ®istration, + &root, + &authority_database, + ) + .map_err(Error::from) + }) + .await?; *cached = Some(drive.clone()); Ok(drive) } @@ -114,9 +117,9 @@ impl WorkspaceDriveHost { return Ok(()); } let drive = self.open(storage, root, authority_database).await?; - tokio::task::spawn_blocking(move || drive.fence().map_err(Error::from)) + crate::db_executor::DbExecutor::shared() + .run(move || drive.fence().map_err(Error::from)) .await - .map_err(|error| Error::Store(format!("Drive fence task failed: {error}")))? } pub(crate) async fn purge( @@ -129,20 +132,20 @@ impl WorkspaceDriveHost { return Ok(()); } let drive = self.open(storage, root.clone(), authority_database).await?; - tokio::task::spawn_blocking(move || -> Result<()> { - reject_symlink_ancestors(&root)?; - // Domain purge persists its tombstone before removing blobs/nodes/receipts. - drive.purge()?; - // Remove the empty blob root, never the data root or another Workspace. - // Missing roots are successful retries; other failures retain FeatureStorage. - match std::fs::remove_dir_all(&root) { - Ok(()) => Ok(()), - Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), - Err(error) => Err(error.into()), - } - }) - .await - .map_err(|error| Error::Store(format!("Drive purge task failed: {error}")))??; + crate::db_executor::DbExecutor::shared() + .run(move || -> Result<()> { + reject_symlink_ancestors(&root)?; + // Domain purge persists its tombstone before removing blobs/nodes/receipts. + drive.purge()?; + // Remove the empty blob root, never the data root or another Workspace. + // Missing roots are successful retries; other failures retain FeatureStorage. + match std::fs::remove_dir_all(&root) { + Ok(()) => Ok(()), + Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()), + Err(error) => Err(error.into()), + } + }) + .await?; // A later signing-material/finalization failure can retry after the // feature DB has been closed and removed by FeatureStorage.delete. self.purged.store(true, Ordering::Release); diff --git a/crates/workspace-server/src/workspace_signing_identity.rs b/crates/workspace-server/src/workspace_signing_identity.rs index 059399dcb..ad2ad587a 100644 --- a/crates/workspace-server/src/workspace_signing_identity.rs +++ b/crates/workspace-server/src/workspace_signing_identity.rs @@ -791,7 +791,7 @@ mod tests { let temp = tempfile::tempdir().unwrap(); let database_path = temp.path().join("server.db"); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database_path).unwrap()); store .upsert_account(&AccountRecord { account_id: "account-1".to_string(), @@ -844,7 +844,7 @@ mod tests { drop(failing_service); drop(store); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database_path).unwrap()); let service = WorkspaceSigningIdentityService::new(store.clone(), materials.clone()); let provisioned = service .provision_existing("workspace-1", "account-1") @@ -973,7 +973,7 @@ mod tests { ); drop(service); drop(store); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let store = Arc::new(SqliteWorkspaceStore::open_for_test(&database_path).unwrap()); let restarted = WorkspaceSigningIdentityService::new(store.clone(), materials.clone()); let recovered = restarted .provision_existing("workspace-2", "account-1") diff --git a/crates/workspace-server/src/workspace_subscription.rs b/crates/workspace-server/src/workspace_subscription.rs index 9e7698a30..1994c627f 100644 --- a/crates/workspace-server/src/workspace_subscription.rs +++ b/crates/workspace-server/src/workspace_subscription.rs @@ -1,6 +1,14 @@ +//! Workspace multiplexer over the shared identity-bound Worker method sender. +//! +//! Separate bounded control/protocol lanes preserve control priority. Writer deadline/exit +//! cancels receive/admission even while waiting for session ownership or control capacity; +//! receive exit aborts/joins writer and subscriptions rather than draining a congested wire. +//! Caller cancellation owns abort-on-drop tasks. Bridge overflow reports Lagged when a terminal +//! control frame can be delivered, never normal resource disappearance. Reconnect/resubscribe +//! for a fresh snapshot; termination proves neither method non-execution nor Worker shutdown. use std::collections::HashMap; -use axum::extract::ws::{Message as WsMessage, WebSocket}; +use axum::extract::ws::Message as WsMessage; use futures::{SinkExt, StreamExt}; use protocol::subscription::{ EventSubscriptionSelector, SubscriptionEvent, SubscriptionEventPayload, SubscriptionFrame, @@ -12,27 +20,43 @@ use worker_runtime::identity::RuntimeWorkerRef; use crate::runtime_subscription::RuntimeSubscriptionBroker; use crate::server::{ - WorkerOperationContext, WorkspaceApi, WorkspaceWorker, WorkspaceWorkerMethodSender, + WorkerOperationContext, WorkerProtocolEnd, WorkspaceApi, WorkspaceWorker, + WorkspaceWorkerMethodSender, }; const OUTBOUND_CAPACITY: usize = 256; const WORKER_PROTOCOL_SNAPSHOT_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); +// Every spawned writer/subscription is connection-owned, including caller cancellation. +struct SubscriptionTask(tokio::task::JoinHandle<()>); +impl Drop for SubscriptionTask { + fn drop(&mut self) { + self.0.abort(); + } +} struct ActiveSubscription { - task: tokio::task::JoinHandle<()>, + task: SubscriptionTask, methods: Option, } -pub(crate) async fn serve_workspace_subscription( +pub(crate) async fn serve_workspace_subscription( api: WorkspaceApi, - socket: WebSocket, + socket: S, context: WorkerOperationContext, -) { +) where + S: futures::Stream> + + futures::Sink + + Unpin + + Send + + 'static, + S::Error: Send, +{ let broker = api.runtime_subscription_broker().clone(); let (mut socket_sender, mut socket_receiver) = socket.split(); let (control_outbound, mut control_receiver) = mpsc::channel::(OUTBOUND_CAPACITY); let (protocol_outbound, mut protocol_receiver) = mpsc::channel::(OUTBOUND_CAPACITY); - let writer = tokio::spawn(async move { + let (writer_done, finished) = tokio::sync::oneshot::channel(); + let mut writer = SubscriptionTask(tokio::spawn(async move { loop { let message = tokio::select! { biased; @@ -40,160 +64,181 @@ pub(crate) async fn serve_workspace_subscription( message = protocol_receiver.recv() => message, }; let Some(message) = message else { break }; - if socket_sender.send(message).await.is_err() { + if !matches!( + tokio::time::timeout( + WORKER_PROTOCOL_SNAPSHOT_TIMEOUT, + socket_sender.send(message) + ) + .await, + Ok(Ok(())) + ) { break; } } - }); + let _ = writer_done.send(()); + })); let mut next_subscription_id = 1_u64; let mut subscriptions = HashMap::::new(); - while let Some(message) = socket_receiver.next().await { - let Ok(message) = message else { break }; - match message { - WsMessage::Text(text) => { - let Ok(frame) = serde_json::from_str::(text.as_str()) else { - break; - }; - if frame.validate().is_err() { - break; - } - subscriptions.retain(|_, subscription| !subscription.task.is_finished()); - match frame.payload { - SubscriptionFramePayload::Request(SubscriptionRequest::SubscribeEvents { - request_id, - selector, - }) => { - let subscription_id = SubscriptionId::new(format!( - "workspace-subscription-{next_subscription_id}" - )) - .expect("generated Workspace subscription id is valid"); - next_subscription_id = next_subscription_id.saturating_add(1); - match selector { - EventSubscriptionSelector::WorkspaceWorkers => { - let task = tokio::spawn(run_workspace_workers( - api.clone(), - broker.clone(), - request_id, - subscription_id.clone(), - control_outbound.clone(), - )); - subscriptions.insert( - subscription_id, - ActiveSubscription { - task, - methods: None, - }, - ); - } - EventSubscriptionSelector::WorkerProtocol { - worker_id, - runtime_id: Some(runtime_id), - } => { - let connection = match WorkspaceWorker::resolve( - &api, - &runtime_id, - worker_id.as_str(), - ) { - Ok(worker) => worker - .connect_protocol(&context) - .await - .map_err(|error| error.to_string()), - Err(error) => Err(error.to_string()), - }; - match connection { - Ok(connection) => { - let methods = connection.sender.clone(); - let task = tokio::spawn(run_worker_protocol( - request_id, - subscription_id.clone(), - runtime_id, - worker_id, - connection.events, - control_outbound.clone(), - protocol_outbound.clone(), - )); - subscriptions.insert( - subscription_id, - ActiveSubscription { - task, - methods: Some(methods), - }, - ); - } - Err(error) => { - let _ = send_rejected( - &control_outbound, - request_id, - SubscriptionRejectionCode::ResourceNotFound, - error.to_string(), - ) - .await; + let receiving = async { + while let Some(message) = socket_receiver.next().await { + let Ok(message) = message else { break }; + match message { + WsMessage::Text(text) => { + let Ok(frame) = serde_json::from_str::(text.as_str()) else { + break; + }; + if frame.validate().is_err() { + break; + } + subscriptions.retain(|_, subscription| !subscription.task.0.is_finished()); + match frame.payload { + SubscriptionFramePayload::Request( + SubscriptionRequest::SubscribeEvents { + request_id, + selector, + }, + ) => { + let subscription_id = SubscriptionId::new(format!( + "workspace-subscription-{next_subscription_id}" + )) + .expect("generated Workspace subscription id is valid"); + next_subscription_id = next_subscription_id.saturating_add(1); + match selector { + EventSubscriptionSelector::WorkspaceWorkers => { + let task = tokio::spawn(run_workspace_workers( + api.clone(), + broker.clone(), + request_id, + subscription_id.clone(), + control_outbound.clone(), + )); + subscriptions.insert( + subscription_id, + ActiveSubscription { + task: SubscriptionTask(task), + methods: None, + }, + ); + } + EventSubscriptionSelector::WorkerProtocol { + worker_id, + runtime_id: Some(runtime_id), + } => { + let connection = match WorkspaceWorker::resolve_async( + &api, + &runtime_id, + worker_id.as_str(), + ) + .await + { + Ok(worker) => worker + .connect_protocol(&context) + .await + .map_err(|error| error.to_string()), + Err(error) => Err(error.to_string()), + }; + match connection { + Ok(connection) => { + let methods = connection.sender.clone(); + let task = tokio::spawn(run_worker_protocol( + request_id, + subscription_id.clone(), + runtime_id, + worker_id, + connection.events, + methods.end_status(), + control_outbound.clone(), + protocol_outbound.clone(), + )); + subscriptions.insert( + subscription_id, + ActiveSubscription { + task: SubscriptionTask(task), + methods: Some(methods), + }, + ); + } + Err(error) => { + let _ = send_rejected( + &control_outbound, + request_id, + SubscriptionRejectionCode::ResourceNotFound, + error.to_string(), + ) + .await; + } } } - } - _ => { - let _ = send_rejected( + _ => { + let _ = send_rejected( &control_outbound, request_id, SubscriptionRejectionCode::UnsupportedSelector, "Workspace clients may subscribe only to workspace_workers or a runtime-scoped worker_protocol selector".to_string(), ).await; + } } } - } - SubscriptionFramePayload::Request(SubscriptionRequest::UnsubscribeEvents { - request_id, - subscription_id, - }) => { - if let Some(subscription) = subscriptions.remove(&subscription_id) { - subscription.task.abort(); + SubscriptionFramePayload::Request( + SubscriptionRequest::UnsubscribeEvents { + request_id, + subscription_id, + }, + ) => { + if let Some(mut subscription) = subscriptions.remove(&subscription_id) { + subscription.task.0.abort(); + let _ = (&mut subscription.task.0).await; + } + if send_frame( + &control_outbound, + SubscriptionFrame::new(SubscriptionFramePayload::Response( + SubscriptionResponse::Unsubscribed { + request_id, + subscription_id, + }, + )), + ) + .await + .is_err() + { + break; + } } - if send_frame( - &control_outbound, - SubscriptionFrame::new(SubscriptionFramePayload::Response( - SubscriptionResponse::Unsubscribed { - request_id, - subscription_id, - }, - )), - ) - .await - .is_err() - { + SubscriptionFramePayload::WorkerProtocol(message) => { + let Some(methods) = subscriptions + .get(&message.subscription_id) + .and_then(|value| value.methods.clone()) + else { + break; + }; + if methods.send(&context, message.method).await.is_err() { + break; + } + } + SubscriptionFramePayload::Response(_) + | SubscriptionFramePayload::Event(_) => { break; } } - SubscriptionFramePayload::WorkerProtocol(message) => { - let Some(methods) = subscriptions - .get(&message.subscription_id) - .and_then(|value| value.methods.clone()) - else { - break; - }; - if methods.send(&context, message.method).await.is_err() { - break; - } - } - SubscriptionFramePayload::Response(_) | SubscriptionFramePayload::Event(_) => { + } + WsMessage::Ping(value) => { + if control_outbound.send(WsMessage::Pong(value)).await.is_err() { break; } } + WsMessage::Pong(_) => {} + WsMessage::Close(_) | WsMessage::Binary(_) => break, } - WsMessage::Ping(value) => { - if control_outbound.send(WsMessage::Pong(value)).await.is_err() { - break; - } - } - WsMessage::Pong(_) => {} - WsMessage::Close(_) | WsMessage::Binary(_) => break, } + }; + // Writer termination also preempts method authorization/control queue waits. + tokio::select! { _ = receiving => {}, _ = finished => {} } + for (_, mut subscription) in subscriptions { + subscription.task.0.abort(); + let _ = (&mut subscription.task.0).await; } - - for (_, subscription) in subscriptions { - subscription.task.abort(); - } - drop(control_outbound); - drop(protocol_outbound); - let _ = writer.await; + // Never wait for a congested writer to drain after disconnect or admission failure. + writer.0.abort(); + let _ = (&mut writer.0).await; } async fn send_rejected( @@ -222,6 +267,7 @@ async fn run_worker_protocol( runtime_id: String, worker_id: protocol::subscription::SubscriptionWorkerId, mut events: mpsc::Receiver, + end_status: tokio::sync::watch::Receiver>, control_outbound: mpsc::Sender, protocol_outbound: mpsc::Sender, ) { @@ -313,8 +359,8 @@ async fn run_worker_protocol( SubscriptionFrame::new(SubscriptionFramePayload::Event( SubscriptionEvent::SubscriptionClosed { subscription_id, - code: SubscriptionTerminationCode::ResourceGone, - message: "Worker protocol stream closed".to_string(), + code: if matches!(*end_status.borrow(), Some(WorkerProtocolEnd::EventOverflow | WorkerProtocolEnd::MethodOverflow)) { SubscriptionTerminationCode::Lagged } else { SubscriptionTerminationCode::ResourceGone }, + message: "Worker protocol stream ended; pending method delivery may be unknown; resubscribe with a fresh snapshot, no automatic retry".to_string(), }, )), ) @@ -330,7 +376,8 @@ async fn run_workspace_workers( ) { let Ok(mut projection) = api .worker_projection - .subscribe_ordered(api.config.max_records.max(1)) + .subscribe_ordered_async(api.config.max_records.max(1)) + .await else { let _ = send_rejected( &outbound, @@ -341,11 +388,22 @@ async fn run_workspace_workers( .await; return; }; - let mut workers = projection - .take_snapshot() - .into_iter() - .filter_map(|record| project_registry_worker(&api, record)) - .collect::>(); + let Ok(mut workers) = project_registry_workers_async( + api.store.clone(), + api.config.workspace_id.clone(), + projection.take_snapshot(), + ) + .await + else { + let _ = send_rejected( + &outbound, + request_id, + SubscriptionRejectionCode::Internal, + "failed to enrich durable Worker projection".to_string(), + ) + .await; + return; + }; sort_workers(&mut workers); if send_frame( &outbound, @@ -370,7 +428,29 @@ async fn run_workspace_workers( for change in event.changes { let payload = match change { crate::worker_projection::WorkerProjectionChange::Upsert(record) => { - let Some(worker) = project_registry_worker(&api, record) else { + let workers = project_registry_workers_async( + api.store.clone(), + api.config.workspace_id.clone(), + vec![record], + ) + .await; + let Ok(mut workers) = workers else { + // Admission failure cannot silently lose a live + // update: terminate and require a fresh snapshot. + let _ = send_frame( + &outbound, + SubscriptionFrame::new(SubscriptionFramePayload::Event( + SubscriptionEvent::SubscriptionClosed { + subscription_id: subscription_id.clone(), + code: SubscriptionTerminationCode::Lagged, + message: "Worker projection enrichment unavailable; reconnect for a fresh snapshot".to_string(), + }, + )), + ) + .await; + return; + }; + let Some(worker) = workers.pop() else { continue; }; SubscriptionEventPayload::WorkerUpserted { worker } @@ -414,8 +494,28 @@ async fn run_workspace_workers( } } +async fn project_registry_workers_async( + store: std::sync::Arc, + workspace_id: String, + records: Vec, +) -> crate::Result> { + store + .db_executor() + .run(move || { + // Keep the committed Worker payload; only the existing Workspace-scoped + // workdir/history/repository enrichment reads occur inside this job. + Ok(records + .into_iter() + .filter_map(|record| project_registry_worker(store.as_ref(), &workspace_id, record)) + .collect()) + }) + .await +} + +// Synchronous helpers stay synchronous; they run only inside the job above. fn project_registry_worker( - api: &WorkspaceApi, + store: &dyn crate::store::ControlPlaneStore, + workspace_id: &str, record: crate::store::WorkerRegistryProjectionRecord, ) -> Option { let runtime_id = record.registry.worker.runtime_id.clone(); @@ -455,29 +555,25 @@ fn project_registry_worker( attempt_id: job.attempt_id, purpose: job.purpose, }); - if !project_working_directory(api, &record.registry.worker, &mut worker) { + if !project_working_directory(store, workspace_id, &record.registry.worker, &mut worker) { return None; } Some(worker) } fn project_working_directory( - api: &WorkspaceApi, + store: &dyn crate::store::ControlPlaneStore, + workspace_id: &str, worker_ref: &RuntimeWorkerRef, worker: &mut SubscriptionWorker, ) -> bool { - let Ok(links) = api - .store - .list_worker_workdir_links(&api.config.workspace_id, worker_ref) - else { + let Ok(links) = store.list_worker_workdir_links(workspace_id, worker_ref) else { return false; }; if !links.is_empty() { let mut attachments = Vec::with_capacity(links.len()); for link in links { - let Ok(Some(workdir)) = api - .store - .get_workdir_registry(&api.config.workspace_id, &link.workdir_id) + let Ok(Some(workdir)) = store.get_workdir_registry(workspace_id, &link.workdir_id) else { return false; }; @@ -497,9 +593,8 @@ fn project_working_directory( } worker.workdir_attachments = attachments; } else { - let Ok(has_link_history) = api - .store - .worker_workdir_link_history_exists(&api.config.workspace_id, worker_ref) + let Ok(has_link_history) = + store.worker_workdir_link_history_exists(workspace_id, worker_ref) else { return false; }; @@ -507,13 +602,17 @@ fn project_working_directory( worker.workdir_attachments.clear(); } } - project_repository_keys(api, worker) + project_repository_keys(store, workspace_id, worker) } -fn project_repository_keys(api: &WorkspaceApi, worker: &mut SubscriptionWorker) -> bool { +fn project_repository_keys( + store: &dyn crate::store::ControlPlaneStore, + workspace_id: &str, + worker: &mut SubscriptionWorker, +) -> bool { project_repository_keys_with(worker, |repository_id| { - api.store - .get_repository(&api.config.workspace_id, repository_id) + store + .get_repository(workspace_id, repository_id) .ok() .flatten() .map(|repository| repository.repository_key) @@ -598,9 +697,8 @@ mod tests { protocol::subscription::SubscriptionWorkerId::new("worker-1").unwrap() } - #[test] - fn external_workdir_attachment_does_not_remove_workspace_worker() { - let mut worker = SubscriptionWorker { + fn external_worker() -> SubscriptionWorker { + SubscriptionWorker { restore_observation_token: None, worker_id: worker_id(), runtime_id: Some("runtime-1".to_string()), @@ -624,8 +722,12 @@ mod tests { .unwrap(), }, ], - }; + } + } + #[test] + fn external_workdir_attachment_does_not_remove_workspace_worker() { + let mut worker = external_worker(); assert!(project_repository_keys_with(&mut worker, |_| { panic!("External Workdir must not require Repository projection") })); @@ -637,6 +739,110 @@ mod tests { ); } + #[tokio::test(flavor = "current_thread")] + async fn async_enrichment_preserves_committed_worker_and_external_attachment_but_hides_unresolved_repository() + { + use crate::store::{ + AccountRecord, ControlPlaneStore, SqliteWorkspaceStore, WorkerRegistryRecord, + WorkspaceRecord, + }; + let store = std::sync::Arc::new( + SqliteWorkspaceStore::in_memory_for_test() + .unwrap() + .with_db_executor(crate::db_executor::DbExecutor::new(Default::default()).unwrap()), + ); + store + .upsert_account(&AccountRecord { + account_id: "owner".into(), + kind: "user".into(), + handle: "owner".into(), + display_name: "Owner".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "workspace-1".into(), + owner_account_id: "owner".into(), + display_name: "Enrichment".into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .await + .unwrap(); + let worker_ref = RuntimeWorkerRef::new("runtime-1", "worker-1"); + store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: "workspace-1".into(), + worker: worker_ref.clone(), + display_name: "Durable name".into(), + profile: Some("builtin:coder".into()), + retention_state: "normal".into(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: "1".into(), + updated_at: "1".into(), + }) + .unwrap(); + store + .apply_worker_registry_observation("workspace-1", "runtime-1", &external_worker(), "2") + .unwrap(); + let committed = store + .worker_registry_projection("workspace-1", &worker_ref) + .unwrap() + .unwrap(); + // Change authority after capturing the payload, as can happen while async + // enrichment is queued. It must not re-query and replace the committed Worker. + let mut later = external_worker(); + later.state = protocol::subscription::SubscriptionWorkerState::Running; + store + .apply_worker_registry_observation("workspace-1", "runtime-1", &later, "3") + .unwrap(); + let projected = project_registry_workers_async( + store.clone(), + "workspace-1".into(), + vec![committed.clone()], + ) + .await + .unwrap(); + assert_eq!(projected.len(), 1); + let worker = &projected[0]; + assert_eq!( + worker.state, + protocol::subscription::SubscriptionWorkerState::Idle + ); + assert_eq!( + worker.availability, + protocol::subscription::SubscriptionWorkerAvailability::Observed + ); + assert_eq!(worker.display_name.as_deref(), Some("Durable name")); + assert_eq!(worker.profile.as_deref(), Some("builtin:coder")); + assert_eq!(worker.runtime_id.as_deref(), Some("runtime-1")); + assert_eq!(worker.workspace_id.as_deref(), Some("workspace-1")); + assert_eq!( + worker.workdir_attachments, + external_worker().workdir_attachments + ); + let mut unresolved = committed; + unresolved + .observation + .as_mut() + .unwrap() + .worker + .workdir_attachments[0] + .repository_id = Some("missing-repository".into()); + assert!( + project_registry_workers_async(store, "workspace-1".into(), vec![unresolved]) + .await + .unwrap() + .is_empty() + ); + } + async fn run_with_events(events: mpsc::Receiver) -> SubscriptionResponse { let (control_outbound, mut control_receiver) = mpsc::channel(1); let (protocol_outbound, _protocol_receiver) = mpsc::channel(1); @@ -646,6 +852,7 @@ mod tests { "runtime-1".to_string(), worker_id(), events, + tokio::sync::watch::channel(None).1, control_outbound, protocol_outbound, ) @@ -660,6 +867,81 @@ mod tests { response } + #[tokio::test] + async fn worker_bridge_overflow_is_serialized_as_lagged_not_resource_disappearance() { + for (reason, expected) in [ + ( + WorkerProtocolEnd::EventOverflow, + SubscriptionTerminationCode::Lagged, + ), + ( + WorkerProtocolEnd::MethodOverflow, + SubscriptionTerminationCode::Lagged, + ), + ( + WorkerProtocolEnd::Disconnected, + SubscriptionTerminationCode::ResourceGone, + ), + ] { + let (event_sender, events) = mpsc::channel(1); + event_sender + .try_send(protocol::Event::Snapshot { + session: protocol::SessionSnapshot { + entries: vec![], + pending_submissions: Default::default(), + }, + greeting: protocol::Greeting { + worker_name: "worker-1".into(), + cwd: String::new(), + provider: "fixture".into(), + model: "fixture".into(), + reasoning: None, + scope_summary: String::new(), + tools: vec![], + context_window: 0, + context_tokens: 0, + context_usage: None, + }, + state: protocol::WorkerStateSnapshot::initial(), + in_flight: Default::default(), + internal_workers: vec![], + }) + .unwrap(); + drop(event_sender); + let (control, mut receiver) = mpsc::channel(2); + let (protocol, _events) = mpsc::channel(1); + run_worker_protocol( + request_id(), + subscription_id(), + "runtime-1".into(), + worker_id(), + events, + tokio::sync::watch::channel(Some(reason)).1, + control, + protocol, + ) + .await; + let WsMessage::Text(snapshot) = receiver.recv().await.unwrap() else { + panic!("expected snapshot response"); + }; + let snapshot: SubscriptionFrame = serde_json::from_str(snapshot.as_str()).unwrap(); + assert!(matches!( + snapshot.payload, + SubscriptionFramePayload::Response(SubscriptionResponse::Subscribed { .. }) + )); + let WsMessage::Text(closed) = receiver.recv().await.unwrap() else { + panic!("expected terminal control frame"); + }; + let frame: SubscriptionFrame = serde_json::from_str(closed.as_str()).unwrap(); + frame.validate().unwrap(); + assert!( + matches!(frame.payload, SubscriptionFramePayload::Event(SubscriptionEvent::SubscriptionClosed { code, message, .. }) + if code == expected && message.contains("unknown") && message.contains("fresh snapshot")), + "{closed}" + ); + } + } + #[tokio::test] async fn worker_protocol_rejects_a_non_snapshot_initial_event() { let (event_sender, events) = mpsc::channel(1); diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 3f108629f..a8dd9d9d1 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -4,9 +4,10 @@ use std::path::PathBuf; use client::{BackendWorkspaceProductClient, ResolvedTarget}; use ticket::{ - MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, - TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketListQuery, TicketListState, - TicketRelationKind, TicketSummary, TicketWorkflowState, + NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, TicketCaseAction, + TicketCaseUpdate, TicketClosureReason, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, + TicketListQuery, TicketListState, TicketReference, TicketRelationKind, TicketState, + TicketSummary, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -26,8 +27,7 @@ pub enum TicketCommand { List(ListOptions), Show { query: String }, Comment(CommentOptions), - State(StateOptions), - Close(CloseOptions), + Case(CaseOptions), Relation(RelationOptions), Doctor, } @@ -57,26 +57,20 @@ pub struct CommentOptions { pub body: BodySource, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StateTarget { - Planning, - Ready, - Queued, - InProgress, - Done, - Closed, -} - +/// Explicit preconditions are required instead of silently refreshing them from +/// show. Repeating the same argv (and file bytes) submits the exact same request, +/// even after the original action changed the case state or closure key. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct StateOptions { +pub struct CaseOptions { pub query: String, - pub state: StateTarget, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CloseOptions { - pub query: String, - pub resolution: BodySource, + pub expected_content_digest: String, + pub expected_state: TicketState, + pub expected_closure_operation_key: Option, + pub operation_key: String, + pub action: TicketCaseAction, + pub explanation: BodySource, + pub references: Vec, + pub author: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -157,8 +151,9 @@ pub fn parse_ticket_args(args: &[String]) -> Result { query: parse_one_positional("show", &args[1..])?, }, "comment" => TicketCommand::Comment(parse_comment(&args[1..])?), - "state" => TicketCommand::State(parse_state(&args[1..])?), - "close" => TicketCommand::Close(parse_close(&args[1..])?), + "close" | "reopen" | "correct-closure" => { + TicketCommand::Case(parse_case(&args[0], &args[1..])?) + } "relation" => TicketCommand::Relation(parse_relation(&args[1..])?), "doctor" => { if args.len() != 1 { @@ -211,8 +206,7 @@ fn run_backend_command( TicketCommand::List(options) => list(backend, options), TicketCommand::Show { query } => show(backend, query), TicketCommand::Comment(options) => comment(backend, options), - TicketCommand::State(options) => state(backend, options), - TicketCommand::Close(options) => close(backend, options), + TicketCommand::Case(options) => update_case(backend, options), TicketCommand::Relation(options) => relation(backend, options), TicketCommand::Doctor => doctor(backend), } @@ -272,6 +266,19 @@ fn show(backend: &dyn TicketBackend, query: String) -> Result Result) -> String { + match key { + None => "none".into(), + Some(key) if key == "none" || key.starts_with("key:") => format!("key:{key}"), + Some(key) => key.into(), + } +} + fn is_obsolete_ticket_frontmatter_key(key: &str) -> bool { matches!( key, @@ -411,63 +428,52 @@ fn comment( Ok(success(format!("appended\t{}\t{}\n", options.query, role))) } -fn state( +fn update_case( backend: &dyn TicketBackend, - options: StateOptions, + options: CaseOptions, ) -> Result { - let id = TicketIdOrSlug::Query(options.query.clone()); - let target_state = match options.state { - StateTarget::Planning => TicketWorkflowState::Planning, - StateTarget::Ready => { - return Err(TicketCliError::new( - "ready requires Workspace repository authority; use the Browser Mark ready action or TicketMarkReady", - )); - } - StateTarget::Queued => { - return Err(TicketCliError::new( - "queued is an Orchestrator operation; use TicketQueue after MarkReady succeeds", - )); - } - StateTarget::InProgress => TicketWorkflowState::InProgress, - StateTarget::Done => { - return Err(TicketCliError::new( - "done is guarded by CompleteMergeRequest with an approved exact source ref and operation_id", - )); - } - StateTarget::Closed => { - return Err(TicketCliError::new( - "yoi ticket state closed cannot write resolution.md; use `yoi ticket close --resolution ` instead", - )); - } + let explanation = match &options.explanation { + BodySource::Message(text) => text.clone(), + BodySource::File(path) => fs::read_to_string(path).map_err(|error| { + TicketCliError::new(format!("failed to read {}: {error}", path.display())) + })?, }; - let current = backend.show(id.clone())?; - let ticket_id = current.meta.id.clone(); - let from = current.meta.workflow_state; - let change = ticket::TicketStateChange { - from: from.as_str().to_string(), - to: target_state.as_str().to_string(), - reason: "cli_state".to_string(), - author: Some("yoi ticket".to_string()), - body: format!("State changed to `{}`.\n", target_state.as_str()).into(), - references: Vec::new(), - }; - backend.set_workflow_state(id, change)?; + require_nonblank("explanation", &explanation)?; + // Resolve identity only. Never rewrite the explicit CAS with this snapshot: + // doing so would change the fingerprint of an exact replay. + let current = backend.show(TicketIdOrSlug::Query(options.query))?; + let operation_key = options.operation_key.clone(); + let updated = backend.update_case( + ¤t.meta.id, + TicketCaseUpdate { + expected_content_digest: options.expected_content_digest, + expected_state: options.expected_state, + expected_closure_operation_key: options.expected_closure_operation_key, + operation_key: options.operation_key, + action: options.action, + explanation, + references: options.references, + author: Some(options.author), + }, + )?; + // A replay returns current authority, which may already have been reopened. + // Report that state, not an inferred "closed"/"completed" success. Ok(success(format!( - "state\t{}\t{}\n", - ticket_id, - target_state.as_str() + "case\t{}\t{}\toperation-key:{}\n", + updated.meta.id, + updated.meta.workflow_state.as_str(), + operation_key ))) } -fn close( - backend: &dyn TicketBackend, - options: CloseOptions, -) -> Result { - backend.close( - TicketIdOrSlug::Query(options.query.clone()), - MarkdownText::new(read_body_source(&options.resolution)?), - )?; - Ok(success(format!("closed\t{}\n", options.query))) +fn closure_reason_label(reason: &TicketClosureReason) -> String { + match reason { + TicketClosureReason::Completed => "completed".into(), + TicketClosureReason::NotPlanned => "not_planned".into(), + TicketClosureReason::Duplicate => "duplicate".into(), + TicketClosureReason::Unknown => "unknown".into(), + TicketClosureReason::Other(text) => format!("other:{text}"), + } } fn relation( @@ -721,49 +727,154 @@ fn parse_comment(args: &[String]) -> Result { }) } -fn parse_state(args: &[String]) -> Result { - if args.len() != 2 { - return Err(TicketCliError::new( - "state requires ", - )); - } - Ok(StateOptions { - query: args[0].clone(), - state: parse_state_target(&args[1])?, - }) -} - -fn parse_close(args: &[String]) -> Result { +fn parse_case(command: &str, args: &[String]) -> Result { if args.is_empty() || args[0].starts_with('-') { - return Err(TicketCliError::new("close requires ")); + return Err(TicketCliError::new(format!("{command} requires "))); } let query = args[0].clone(); + let mut digest = None; + let mut state = None; + let mut closure_key = None; + let mut operation_key = None; + let mut reason = None; + let mut explanation = None; let mut file = None; - let mut resolution = None; + let mut references = Vec::new(); + let mut author = None; + let mut seen = std::collections::BTreeSet::new(); let mut i = 1; while i < args.len() { - match option_with_value(args, &mut i)? { - Some(("--resolution", value)) => resolution = Some(value), - Some(("--file", value)) => file = Some(PathBuf::from(value)), - Some((name, _)) => { - return Err(TicketCliError::new(format!( - "unknown close argument: {name}" - ))); + let Some((name, value)) = option_with_value(args, &mut i)? else { + return Err(TicketCliError::new(format!( + "unknown {command} argument: {}", + args[i] + ))); + }; + if name != "--reference" && !seen.insert(name) { + return Err(TicketCliError::new(format!( + "{name} may only be supplied once" + ))); + } + require_nonblank(name, &value)?; + match name { + "--expected-content-digest" => { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(TicketCliError::new( + "--expected-content-digest requires a SHA-256 hex digest", + )); + } + digest = Some(value); } - None => { + "--expected-state" => { + state = Some(TicketState::parse(&value).ok_or_else(|| { + TicketCliError::new("--expected-state requires open or closed") + })?) + } + "--expected-closure-operation-key" => { + closure_key = Some(if value == "none" { + None + } else { + let key = value.strip_prefix("key:").unwrap_or(&value); + require_nonblank("closure operation key", key)?; + Some(key.to_string()) + }); + } + "--operation-key" => operation_key = Some(value), + "--reason" if command != "reopen" => reason = Some(parse_closure_reason(&value)?), + "--explanation" => explanation = Some(value), + "--file" => file = Some(PathBuf::from(value)), + "--reference" => { + let (kind, target) = value + .split_once(':') + .ok_or_else(|| TicketCliError::new("--reference requires :"))?; + require_nonblank("reference kind", kind)?; + require_nonblank("reference target", target)?; + references.push(TicketReference { + kind: kind.into(), + target: target.into(), + }); + } + "--author" => author = Some(value), + _ => { return Err(TicketCliError::new(format!( - "unknown close argument: {}", - args[i] + "unknown {command} argument: {name}" ))); } } } - Ok(CloseOptions { + let action = match command { + "reopen" => TicketCaseAction::Reopen, + "close" => TicketCaseAction::Close { + reason: reason.ok_or_else(|| TicketCliError::new("close requires --reason"))?, + }, + "correct-closure" => TicketCaseAction::CorrectClosure { + reason: reason + .ok_or_else(|| TicketCliError::new("correct-closure requires --reason"))?, + }, + _ => unreachable!("case command is selected by the command parser"), + }; + let explanation = match (file, explanation) { + (Some(path), None) => BodySource::File(path), + (None, Some(text)) => BodySource::Message(text), + _ => { + return Err(TicketCliError::new(format!( + "{command} requires exactly one of --explanation or --file" + ))); + } + }; + Ok(CaseOptions { query, - resolution: exactly_one_body("close", file, resolution)?, + expected_content_digest: digest + .ok_or_else(|| TicketCliError::new("case action requires --expected-content-digest"))?, + expected_state: state + .ok_or_else(|| TicketCliError::new("case action requires --expected-state"))?, + expected_closure_operation_key: closure_key.ok_or_else(|| { + TicketCliError::new("case action requires --expected-closure-operation-key ") + })?, + operation_key: operation_key + .ok_or_else(|| TicketCliError::new("case action requires --operation-key"))?, + action, + explanation, + references, + // A stable default keeps retries independent of process environment. + author: author.unwrap_or_else(|| "yoi ticket".into()), }) } +fn require_nonblank(name: &str, value: &str) -> Result<(), TicketCliError> { + if value.trim().is_empty() || value.contains('\0') { + Err(TicketCliError::new(format!( + "{name} must be nonblank and contain no NUL" + ))) + } else { + Ok(()) + } +} + +fn parse_closure_reason(value: &str) -> Result { + match value { + "completed" => Ok(TicketClosureReason::Completed), + "not_planned" => Ok(TicketClosureReason::NotPlanned), + "duplicate" => Ok(TicketClosureReason::Duplicate), + "unknown" => Ok(TicketClosureReason::Unknown), + _ => { + if let Some(text) = value.strip_prefix("other:") { + require_nonblank("other reason", text)?; + if text.len() > 1024 { + return Err(TicketCliError::new( + "other reason must be at most 1024 UTF-8 bytes", + )); + } + Ok(TicketClosureReason::Other(text.into())) + } else { + Err(TicketCliError::new( + "--reason requires completed, not_planned, duplicate, unknown, or other:", + )) + } + } + } +} + fn parse_one_positional(command: &str, args: &[String]) -> Result { if args.len() != 1 || args[0].starts_with('-') { Err(TicketCliError::new(format!("{command} requires "))) @@ -783,7 +894,14 @@ fn option_with_value( "--role", "--file", "--message", - "--resolution", + "--reason", + "--explanation", + "--expected-content-digest", + "--expected-state", + "--expected-closure-operation-key", + "--operation-key", + "--reference", + "--author", "--ticket", "--kind", "--target", @@ -831,7 +949,7 @@ fn parse_list_state(raw: &str) -> Result { .any(|token| *token == "active" || *token == "all") { return Err(TicketCliError::new( - "--state active/all cannot be mixed with workflow states", + "--state active/all cannot be mixed with case states", )); } @@ -839,7 +957,7 @@ fn parse_list_state(raw: &str) -> Result { for token in tokens { let state = TicketListState::parse(token).ok_or_else(|| { TicketCliError::new(format!( - "invalid state: {token}; expected active, all, planning, ready, queued, inprogress, done, closed" + "invalid state: {token}; expected active, all, open, closed" )) })?; if !states.contains(&state) { @@ -878,18 +996,6 @@ fn truncate_inline(text: &str, max_chars: usize) -> String { out } -fn parse_state_target(value: &str) -> Result { - match value { - "planning" => Ok(StateTarget::Planning), - "ready" => Ok(StateTarget::Ready), - "queued" => Ok(StateTarget::Queued), - "inprogress" => Ok(StateTarget::InProgress), - "done" => Ok(StateTarget::Done), - "closed" => Ok(StateTarget::Closed), - _ => Err(TicketCliError::new(format!("invalid state: {value}"))), - } -} - fn parse_comment_role(value: &str) -> Result { match value { "comment" => Ok(TicketEventKind::Comment), @@ -909,12 +1015,12 @@ fn exactly_one_body( ) -> Result { match (file, message) { (Some(_), Some(_)) => Err(TicketCliError::new(format!( - "{command} accepts exactly one of --file or --message/--resolution" + "{command} accepts exactly one of --file or --message" ))), (Some(path), None) => Ok(BodySource::File(path)), (None, Some(message)) => Ok(BodySource::Message(ensure_trailing_newline(message))), (None, None) => Err(TicketCliError::new(format!( - "{command} requires --file or --message/--resolution" + "{command} requires --file or --message" ))), } } @@ -942,7 +1048,48 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket create --title \n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nTargets:\n Ticket commands require the Workspace-scoped Backend selected by the shared client Target.\n Standalone never falls back to repository-local Ticket storage.\n" + "yoi ticket + +Usage: + yoi ticket create --title <title> + yoi ticket list [--state active|all|open|closed[,..]] [--limit <n>] + yoi ticket show <id> + yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>) + yoi ticket close <id> --reason <reason> <case-options> + yoi ticket reopen <id> <case-options> + yoi ticket correct-closure <id> --reason <reason> <case-options> + yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>] + yoi ticket relation list [--ticket <id>] [--kind <kind>] + yoi ticket doctor + +Case options (required for close, reopen, correct-closure): + --expected-content-digest <sha256> Content digest from show + --expected-state <open|closed> Observed case state + --expected-closure-operation-key <key|none> Last closure key from show, retained after reopening + --operation-key <key> Stable key for this decision + --explanation <text> | --file <path> Exactly one nonblank explanation source + --reference <kind>:<target> Optional; repeat for multiple references + --author <author> Optional; defaults to yoi ticket + +Closure reasons: + completed | not_planned | duplicate | unknown | other:<text> + Closing does not imply completion; a reason must be explicitly supplied. + +Replay: + Repeat the same operation key, CAS values, reason, explanation bytes, references, + and author for an exact replay. Do not refresh CAS values on a retry. + Keep --file contents unchanged. Output reports current Backend case state, + which may differ from the original action after subsequent decisions. + The literal none denotes an absent closure key; active lists open cases. + Prefix a closure key with key: to use a literal value (e.g. key:none or key:key:foo). + +Options: + -h, --help Print help + +Targets: + Ticket commands require the Workspace-scoped Backend selected by the shared client Target. + Standalone never falls back to repository-local Ticket storage. +" } #[cfg(test)] @@ -974,4 +1121,544 @@ mod tests { assert!(output.stdout.contains("Workspace-scoped Backend")); assert!(!output.stdout.contains("repository-file Ticket backend")); } + + fn case_args(command: &str, reason: Option<&str>) -> Vec<String> { + let mut argv = args(&[ + command, + "example", + "--expected-content-digest", + &"a".repeat(64), + "--expected-state", + "open", + "--expected-closure-operation-key", + "none", + "--operation-key", + "decision-1", + "--explanation", + "Intentional decision.", + ]); + if let Some(reason) = reason { + argv.extend(args(&["--reason", reason])); + } + argv + } + + fn command(argv: &[String]) -> TicketCommand { + match parse_ticket_args(argv).unwrap() { + TicketCli::Command(command) => command, + TicketCli::Help => panic!("expected command"), + } + } + + fn replace_value(argv: &mut [String], flag: &str, value: &str) { + let index = argv.iter().position(|arg| arg == flag).unwrap(); + argv[index + 1] = value.into(); + } + + #[test] + fn case_commands_require_explicit_reason_and_explanation_with_replay_preconditions() { + for name in ["close", "reopen", "correct-closure"] { + let reason = (name != "reopen").then_some("unknown"); + let argv = case_args(name, reason); + assert!(parse_ticket_args(&argv).is_ok(), "{name}"); + for flag in [ + "--expected-content-digest", + "--expected-state", + "--expected-closure-operation-key", + "--operation-key", + "--explanation", + ] { + let mut missing = argv.clone(); + let index = missing.iter().position(|arg| arg == flag).unwrap(); + missing.drain(index..index + 2); + assert!( + parse_ticket_args(&missing).is_err(), + "{name} missing {flag}" + ); + let mut blank = argv.clone(); + replace_value(&mut blank, flag, " \n "); + assert!(parse_ticket_args(&blank).is_err(), "{name} blank {flag}"); + } + } + for name in ["close", "correct-closure"] { + assert!( + parse_ticket_args(&case_args(name, None)) + .unwrap_err() + .to_string() + .contains("requires --reason") + ); + } + assert!(parse_ticket_args(&case_args("reopen", Some("completed"))).is_err()); + for name in ["state", "complete"] { + assert!(parse_ticket_args(&args(&[name, "example", "closed"])).is_err()); + } + } + + #[test] + fn close_and_correction_parse_each_reason_without_a_success_default() { + for name in ["close", "correct-closure"] { + for (raw, expected) in [ + ("completed", TicketClosureReason::Completed), + ("not_planned", TicketClosureReason::NotPlanned), + ("duplicate", TicketClosureReason::Duplicate), + ("unknown", TicketClosureReason::Unknown), + ( + "other:Deferred pending budget", + TicketClosureReason::Other("Deferred pending budget".into()), + ), + ] { + let TicketCommand::Case(options) = command(&case_args(name, Some(raw))) else { + panic!() + }; + let actual = match options.action { + TicketCaseAction::Close { reason } + | TicketCaseAction::CorrectClosure { reason } => reason, + TicketCaseAction::Reopen => panic!(), + }; + assert_eq!(actual, expected); + } + for raw in [ + "success", + "other:", + "other: ", + "other:\0", + &format!("other:{}", "é".repeat(513)), + ] { + assert!( + parse_ticket_args(&case_args(name, Some(raw))).is_err(), + "{raw:?}" + ); + } + } + } + + #[test] + fn case_parser_rejects_ambiguous_or_malformed_requests() { + let base = case_args("close", Some("not_planned")); + for (flag, value) in [ + ("--expected-content-digest", "not-a-digest"), + ("--expected-state", "running"), + ("--operation-key", "key\0"), + ("--reference", "missing-separator"), + ("--reference", ":target"), + ("--reference", "result:"), + ("--author", " "), + ] { + let mut argv = base.clone(); + if argv.iter().any(|arg| arg == flag) { + replace_value(&mut argv, flag, value); + } else { + argv.extend(args(&[flag, value])); + } + assert!(parse_ticket_args(&argv).is_err(), "{flag} {value:?}"); + } + for extra in [ + args(&["--file", "explanation.md"]), + args(&["--operation-key", "second"]), + args(&["--resolution", "old untyped close"]), + ] { + let mut argv = base.clone(); + argv.extend(extra); + assert!(parse_ticket_args(&argv).is_err()); + } + } + + #[test] + fn list_accepts_only_case_states_and_active_means_open() { + for (raw, expected) in [ + ("active", ListState::Active), + ("all", ListState::All), + ("open", ListState::States(vec![TicketListState::Open])), + ("closed", ListState::States(vec![TicketListState::Closed])), + ( + "open,closed,open", + ListState::States(vec![TicketListState::Open, TicketListState::Closed]), + ), + ] { + let TicketCommand::List(options) = command(&args(&["list", "--state", raw])) else { + panic!() + }; + assert_eq!(options.state, expected); + } + for raw in ["running", "active,closed", "all,open"] { + assert!(parse_ticket_args(&args(&["list", "--state", raw])).is_err()); + } + let fake = FakeBackend::new(); + run_backend_command(command(&args(&["list"])), &fake).unwrap(); + assert_eq!( + fake.list_filter.borrow().as_ref(), + Some(&TicketListQuery::active()) + ); + } + + // Narrow in-memory adapter: no Workspace, product process, network, or + // filesystem Ticket storage. Unused authority methods panic so no fallback + // event/state/storage operation can make these runtime tests pass. + struct FakeBackend { + current: std::cell::RefCell<ticket::Ticket>, + receipts: std::cell::RefCell<std::collections::BTreeMap<String, TicketCaseUpdate>>, + requests: std::cell::RefCell<Vec<(String, TicketCaseUpdate)>>, + list_filter: std::cell::RefCell<Option<TicketListQuery>>, + } + + fn fixture() -> ticket::Ticket { + ticket::Ticket { + meta: ticket::TicketMeta { + id: "T-example".into(), + resource_key: None, + slug: "example".into(), + title: "Case judgment".into(), + status: ticket::ExtensibleTicketStatus::Open, + kind: "task".into(), + priority: "normal".into(), + labels: vec![], + created_at: None, + updated_at: None, + assignee: None, + readiness: None, + risk_flags: vec![], + workflow_state: TicketState::Open, + workflow_state_explicit: true, + queued_by: None, + queued_at: None, + targets: vec![], + raw: Default::default(), + }, + document: ticket::TicketDocument { + body: "Requirements".into(), + raw_frontmatter: Default::default(), + }, + events: vec![], + artifacts: vec![], + relations: Default::default(), + resolution: None, + closure_operation_key: None, + current_closure: None, + case_history: vec![], + legacy_workflow_snapshot: None, + tags: vec![], + } + } + + impl FakeBackend { + fn new() -> Self { + Self { + current: std::cell::RefCell::new(fixture()), + receipts: Default::default(), + requests: Default::default(), + list_filter: Default::default(), + } + } + + fn argv(&self, action: &str, reason: Option<&str>, key: &str) -> Vec<String> { + let current = self.current.borrow(); + let mut argv = case_args(action, reason); + replace_value( + &mut argv, + "--expected-content-digest", + &ticket::ticket_content_digest(¤t), + ); + replace_value( + &mut argv, + "--expected-state", + current.meta.workflow_state.as_str(), + ); + replace_value( + &mut argv, + "--expected-closure-operation-key", + &closure_key_token(current.closure_operation_key.as_deref()), + ); + replace_value(&mut argv, "--operation-key", key); + argv + } + } + + macro_rules! unused_backend_methods { + ($(fn $name:ident(&self $(, $arg:ident: $ty:ty)*) -> $ret:ty;)*) => { + $(fn $name(&self $(, _: $ty)*) -> $ret { panic!("unexpected authority method: {}", stringify!($name)) })* + }; + } + + impl TicketBackend for FakeBackend { + fn list(&self, filter: TicketListQuery) -> ticket::Result<Vec<TicketSummary>> { + self.list_filter.replace(Some(filter)); + Ok(vec![]) + } + fn show(&self, _: TicketIdOrSlug) -> ticket::Result<ticket::Ticket> { + Ok(self.current.borrow().clone()) + } + fn update_case( + &self, + id: &str, + request: TicketCaseUpdate, + ) -> ticket::Result<ticket::Ticket> { + self.requests + .borrow_mut() + .push((id.into(), request.clone())); + let mut current = self.current.borrow_mut(); + if let Some(receipt) = self.receipts.borrow().get(&request.operation_key) { + if receipt != &request { + return Err(ticket::TicketError::OperationFingerprintMismatch { + operation_key: request.operation_key, + }); + } + return Ok(current.clone()); + } + let digest = ticket::ticket_content_digest(¤t); + if request.expected_content_digest != digest { + return Err(ticket::TicketError::StaleContent { + expected: request.expected_content_digest, + actual: digest, + }); + } + if request.expected_state != current.meta.workflow_state { + return Err(ticket::TicketError::StaleWorkflowState { + expected: request.expected_state.to_string(), + actual: current.meta.workflow_state.to_string(), + }); + } + if request.expected_closure_operation_key != current.closure_operation_key { + return Err(ticket::TicketError::StaleClosureOperationKey { + expected: request.expected_closure_operation_key, + actual: current.closure_operation_key.clone(), + }); + } + match &request.action { + TicketCaseAction::Close { reason } + | TicketCaseAction::CorrectClosure { reason } => { + current.meta.workflow_state = TicketState::Closed; + current.closure_operation_key = Some(request.operation_key.clone()); + current.current_closure = Some(ticket::TicketClosureJudgment { + operation_key: request.operation_key.clone(), + reason: reason.clone(), + explanation: request.explanation.clone(), + references: request.references.clone(), + author: request.author.clone().unwrap(), + at: "fixture-time".into(), + }); + } + TicketCaseAction::Reopen => { + current.meta.workflow_state = TicketState::Open; + current.current_closure = None; + } + } + self.receipts + .borrow_mut() + .insert(request.operation_key.clone(), request); + Ok(current.clone()) + } + unused_backend_methods! { + fn list_workspace_tags(&self, request: ticket::TagListRequest) -> ticket::Result<Vec<ticket::WorkspaceTag>>; + fn mutate_workspace_tag(&self, request: ticket::WorkspaceTagMutation) -> ticket::Result<ticket::WorkspaceTag>; + fn set_ticket_tags(&self, id: TicketIdOrSlug, request: ticket::TicketTagSetRequest) -> ticket::Result<Vec<ticket::WorkspaceTag>>; + fn default_intake_ready_state_change_body(&self, from: &str) -> String; + fn create(&self, input: NewTicket) -> ticket::Result<ticket::TicketRef>; + fn edit_item(&self, id: TicketIdOrSlug, edit: ticket::TicketItemEdit) -> ticket::Result<ticket::Ticket>; + fn dependency_check(&self, id: TicketIdOrSlug) -> ticket::Result<ticket::TicketDependencyCheck>; + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> ticket::Result<()>; + fn add_state_changed(&self, id: TicketIdOrSlug, change: ticket::TicketStateChange) -> ticket::Result<()>; + fn add_intake_summary(&self, id: TicketIdOrSlug, summary: ticket::TicketIntakeSummary) -> ticket::Result<()>; + fn mark_ready(&self, id: TicketIdOrSlug, request: ticket::TicketMarkReady) -> ticket::Result<ticket::Ticket>; + fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> ticket::Result<ticket::TicketQueueOutcome>; + fn add_ticket_relation(&self, id: TicketIdOrSlug, relation: NewTicketRelation) -> ticket::Result<ticket::TicketRelation>; + fn remove_ticket_relation(&self, id: TicketIdOrSlug, kind: TicketRelationKind, target: TicketIdOrSlug) -> ticket::Result<ticket::TicketRelation>; + fn query_ticket_relations(&self, id: Option<TicketIdOrSlug>, kind: Option<TicketRelationKind>) -> ticket::Result<Vec<ticket::TicketRelation>>; + fn relation_view(&self, id: TicketIdOrSlug) -> ticket::Result<ticket::TicketRelationView>; + fn add_orchestration_plan_record(&self, id: TicketIdOrSlug, record: ticket::NewOrchestrationPlanRecord) -> ticket::Result<ticket::OrchestrationPlanRecord>; + fn query_orchestration_plan_records(&self, id: Option<TicketIdOrSlug>, kind: Option<ticket::OrchestrationPlanKind>) -> ticket::Result<Vec<ticket::OrchestrationPlanRecord>>; + fn doctor(&self) -> ticket::Result<ticket::TicketDoctorReport>; + } + } + + #[test] + fn reasoned_actions_use_update_case_and_replay_reports_current_authority() { + let fake = FakeBackend::new(); + let mut close = fake.argv("close", Some("not_planned"), "close-1"); + close.extend(args(&[ + "--author", + "human:alice", + "--reference", + "document:https://example.invalid/result", + "--reference", + "ticket:T-other", + ])); + let output = run_backend_command(command(&close), &fake).unwrap(); + assert_eq!( + output.stdout, + "case\tT-example\tclosed\toperation-key:close-1\n" + ); + let (id, request) = fake.requests.borrow()[0].clone(); + assert_eq!(id, "T-example"); + assert_eq!(request.explanation, "Intentional decision."); + assert_eq!(request.author.as_deref(), Some("human:alice")); + assert_eq!(request.references.len(), 2); + assert_eq!( + serde_json::to_value(&request).unwrap()["action"], + serde_json::json!({"action": "close", "reason": "not_planned"}) + ); + assert_eq!( + request.references[0].target, + "https://example.invalid/result" + ); + assert_eq!( + request.action, + TicketCaseAction::Close { + reason: TicketClosureReason::NotPlanned + } + ); + let correct = fake.argv("correct-closure", Some("duplicate"), "correct-1"); + run_backend_command(command(&correct), &fake).unwrap(); + assert_eq!( + fake.current + .borrow() + .current_closure + .as_ref() + .unwrap() + .reason, + TicketClosureReason::Duplicate + ); + let reopen = fake.argv("reopen", None, "reopen-1"); + run_backend_command(command(&reopen), &fake).unwrap(); + let output = run_backend_command(command(&close), &fake).unwrap(); + assert_eq!( + output.stdout, + "case\tT-example\topen\toperation-key:close-1\n" + ); + assert_eq!(fake.requests.borrow().last().unwrap().1, request); + assert_eq!(fake.receipts.borrow().len(), 3); + assert_eq!( + fake.current.borrow().closure_operation_key.as_deref(), + Some("correct-1") + ); + let mut changed = close; + replace_value(&mut changed, "--explanation", "A different judgment."); + let error = run_backend_command(command(&changed), &fake).unwrap_err(); + assert!(error.to_string().contains("different request fingerprint")); + assert_eq!(fake.current.borrow().meta.workflow_state, TicketState::Open); + } + + #[test] + fn stale_case_preconditions_propagate_backend_rejection_without_retry_or_success() { + for (flag, value, diagnostic) in [ + ( + "--expected-content-digest", + "b".repeat(64), + "stale ticket content", + ), + ( + "--expected-state", + "closed".into(), + "stale ticket workflow state", + ), + ( + "--expected-closure-operation-key", + "unobserved".into(), + "stale Ticket closure operation key", + ), + ] { + let fake = FakeBackend::new(); + let before = fake.current.borrow().clone(); + let mut argv = fake.argv("close", Some("completed"), "decision-1"); + replace_value(&mut argv, flag, &value); + let error = run_backend_command(command(&argv), &fake).unwrap_err(); + assert!(error.to_string().contains(diagnostic), "{error}"); + assert_eq!(*fake.current.borrow(), before); + assert_eq!(fake.requests.borrow().len(), 1); + assert!(fake.receipts.borrow().is_empty()); + } + } + + #[test] + fn explanation_file_preserves_bytes_and_blank_files_never_submit_a_case_action() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("explanation.md"); + let fake = FakeBackend::new(); + let mut argv = fake.argv("close", Some("unknown"), "close-file"); + let index = argv.iter().position(|arg| arg == "--explanation").unwrap(); + argv[index] = "--file".into(); + argv[index + 1] = path.display().to_string(); + fs::write(&path, " \n ").unwrap(); + assert!(run_backend_command(command(&argv), &fake).is_err()); + assert!(fake.requests.borrow().is_empty()); + fs::write(&path, "Decision without trailing newline").unwrap(); + run_backend_command(command(&argv), &fake).unwrap(); + run_backend_command(command(&argv), &fake).unwrap(); + assert_eq!( + fake.requests.borrow()[0].1.explanation, + "Decision without trailing newline" + ); + assert_eq!(fake.requests.borrow()[0].1, fake.requests.borrow()[1].1); + } + + #[test] + fn show_exposes_case_cas_and_explicit_closure_reason() { + let fake = FakeBackend::new(); + let output = run_backend_command(command(&args(&["show", "example"])), &fake).unwrap(); + assert!(output.stdout.contains(&format!( + "Content digest: {}", + ticket::ticket_content_digest(&fake.current.borrow()) + ))); + assert!(output.stdout.contains("Closure operation key: none")); + let argv = fake.argv("close", Some("not_planned"), "close-show"); + run_backend_command(command(&argv), &fake).unwrap(); + let output = run_backend_command(command(&args(&["show", "example"])), &fake).unwrap(); + assert!(output.stdout.contains("State: closed")); + assert!(output.stdout.contains("Closure operation key: close-show")); + assert!(output.stdout.contains("Closure reason: not_planned")); + assert!( + output + .stdout + .contains("Closure explanation: Intentional decision.") + ); + assert!(!output.stdout.contains("Closure reason: completed")); + } + + #[test] + fn help_documents_reasoned_case_actions_and_exact_replay_not_bare_state_mutation() { + let help = help_text(); + for snippet in [ + "yoi ticket close", + "yoi ticket reopen", + "yoi ticket correct-closure", + "--reason", + "--explanation", + "--operation-key", + "Do not refresh CAS values on a retry.", + "active lists open cases", + ] { + assert!(help.contains(snippet), "{snippet}"); + } + assert!(!help.contains("yoi ticket state")); + assert!(!help.contains("--resolution")); + } + + #[test] + fn literal_closure_keys_and_tagged_actions_preserve_the_update_case_wire_contract() { + for (token, key) in [ + ("none", None), + ("key:none", Some("none")), + ("key:key:example", Some("key:example")), + ("close-1", Some("close-1")), + ] { + let mut argv = case_args("reopen", None); + replace_value(&mut argv, "--expected-closure-operation-key", token); + let TicketCommand::Case(options) = command(&argv) else { + panic!() + }; + assert_eq!(options.expected_closure_operation_key.as_deref(), key); + assert_eq!( + serde_json::to_value(&options.action).unwrap(), + serde_json::json!({"action": "reopen"}) + ); + } + let TicketCommand::Case(options) = + command(&case_args("correct-closure", Some("other:Budget changed"))) + else { + panic!() + }; + assert_eq!( + serde_json::to_value(&options.action).unwrap(), + serde_json::json!({"action": "correct_closure", "reason": {"other": "Budget changed"}}) + ); + } } diff --git a/docs/development/work-items.md b/docs/development/work-items.md index ec094dd61..4186f416b 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -1,6 +1,6 @@ # 作業記録と判断の責任 -Ticketには、利用者の意図と、それに対して判定できる成果を記録する。調査、設計、文書、運用も成果となる。コード、レビュー、マージをすべてのTicketに必須とせず、要求と受入条件に従って結果の返却・完了・終了を判断する。 +Ticketには、利用者の意図と、それに対して判定できる成果を記録する。調査、設計、文書、運用も成果となる。コード、レビュー、マージをすべてのTicketに必須とせず、要求と受入条件に従って結果の返却・終了を判断する。 ## 記録の粒度 @@ -8,7 +8,15 @@ Ticketには、利用者の意図と、それに対して判定できる成果 作成・大幅な変更の前に、関連記録と重複を確認する。背景、要求、受入条件、決定事項、実装の裁量、未解決の問いを区別して書く。確認済みの事実、利用者の説明、仮説を混ぜない。担当者の役割制限、そのターンの計画、文書作成後に停止する判断を製品の禁止事項へ転記しない。 -実装の承認待ちは、利用者が求めた場合にだけ作業の条件として記録する。未確認事項は必要な調査で絞り、実装手段を早期に必須要件へ変えない。依存関係は成果間の関係を示すものであり、資源所有や実行順の記録の代わりにはしない。 +実装の承認待ちは、利用者が求めた場合にだけ作業の条件として記録する。未確認事項は必要な調査で絞り、実装手段を早期に必須要件へ変えない。分類タグは検索・分類のために使い、終了理由や着手権限の代用にしない。依存関係は成果間の関係を示すものであり、資源所有や実行順の記録の代わりにはしない。 + +## 案件・担当・実行を分ける + +Ticketの案件状態は`open`と`closed`であり、着手準備や進捗を表さない。終了には理由を残し、`closed`を必ずしも要求充足と解釈しない。Orchestratorの対応状況`waiting`・`inprogress`・`done`は現在の担当期間に対する自己申告であり、案件状態、Worker状態、通知配送、MR判断とは別である。 + +Assignは担当への委譲であり、readyやQueueの操作を前提にしない。通知受理から対応開始を推測せず、待機理由、次の行動、結果を明示する。待機中・作業中への回答は同じ担当期間を継続し、対応終了後の再依頼は新しい担当期間として扱う。案件の再開やWorkerの交代だけでは、再依頼や実行権限を作らない。 + +起動や再試行では、現在の担当と永続操作の結果を確認する。未完の依存関係は部分着手・待機の判断材料であり、一律の起動禁止や関連Ticketの自動起動を意味しない。結果不明の起動を別Workerの起動で置き換えず、保存済み委譲の通知失敗を委譲自体の失敗として再Assignしない。 ## 正本の確認と成果の報告 @@ -16,10 +24,12 @@ Ticketには、利用者の意図と、それに対して判定できる成果 成果には、実際の変更、検証結果、未確認事項、判断に必要な証拠を残す。すべての作業にコメントやartifactを義務付けず、利用者が必要とする返却方法を選ぶ。長い調査結果、生ログ、秘密情報はTicket本文へ転載しない。 +対応終了と案件終了はそれぞれ明示的に判断する。結果を返して案件を開いたままにすることもできる。要求充足の判断は担当Worker・利用者の責任であり、Backendの認可・競合検査の成功や、成果物・承認済みMRの存在が代わりに判定するものではない。 + ## コードの公開とレビュー 公開が作業範囲なら、まとまりのあるcommitを作り、正確なsourceを通常の非force更新で公開する。レビューでは要求、変更全体、検証証拠を独立に確認する。口頭の承認、Profile、Flow遷移を権限の代用にしない。 source変更後は変更後のsourceを再レビューする。targetだけの変化では、未変更sourceへの承認と統合可否の再確認を区別する。通常の修正・再レビュー証拠はMerge Requestに置き、Ticketには調整が必要な阻害要因、決定、最終引継ぎを残す。 -統合、Ticketの結論、Worker停止、接続解除、資源削除は別の判断である。終了通知や最終回答だけから後続操作を開始せず、依頼範囲と現在の正本を確認する。未反映の変更を統合済みと報告しない。 +統合、Ticketの結論、Worker停止、接続解除、資源削除は別の判断である。対応終了後の担当表示の保持と、未完作業による資源拘束も区別する。終了通知や最終回答だけから後続操作を開始せず、依頼範囲と現在の正本を確認する。未反映の変更を統合済みと報告しない。 diff --git a/docs/principle/ux-design.md b/docs/principle/ux-design.md index 9ff3514d7..d67d2b97b 100644 --- a/docs/principle/ux-design.md +++ b/docs/principle/ux-design.md @@ -31,13 +31,15 @@ Workspace Webは、Workspaceの確定した状態を確認し、注意が必要 同じ項目をリソース間で比較する一覧には、平坦なlistまたはtableを使う。人が読む名前とcanonical keyを最初の列に置く。providerの詳細、変更ID、digestは比較用の列へ常設せず、詳細表示や診断用の開閉領域へ置く。 -詳細では、対象の識別情報、現在の状態、注意が必要な事項を最初に示し、本文や履歴を主領域に置く。関連、target、review、workflowの状態をそれぞれ別の主体が確定する場合は、独立したsectionに分ける。 +詳細では、対象の識別情報、現在の状態、注意が必要な事項を最初に示し、本文や履歴を主領域に置く。関連、target、review、案件判断、担当のhandlingをそれぞれ別の主体が確定する場合は、独立したsectionに分ける。 ## Ticket board -boardは横方向にlaneを比較する作業面とする。laneはworkflow stateを表し、隣接laneとの関係を維持する。狭い画面でもboard内の横スクロールで比較できるようにする。 +boardのlaneは案件状態の`open`/`closed`を表し、狭い画面では縦に並べる。`open`を着手可能・queue済み、`closed`を要求実現済みと読み替えない。終了理由と説明を案件の判断として示し、再開・訂正でも過去の判断を残す。 -各Ticketにはkey、title、優先判断を変える情報を表示する。blockerとattentionは文字でも示す。 +各Ticketにはkey、title、優先判断を変える情報を表示する。分類タグ、担当principal、自己申告のhandlingを案件状態と分けて示す。検索と絞込みは取得済みの一部分ではなく、Backendのqueryへ適用してからページを取得する。 + +Orchestratorのhandlingは、担当ごとの自己申告として示す。`done`を案件の終了やMR承認と読み替えず、再依頼は新しい担当、回答・継続は現在の担当への入力として区別する。担当の保存、通知の受理、実作業の着手、Workerの現在状態は独立して示し、未観測・結果不明を成功で補わない。分類、案件判断、担当変更からWorker制御や権限付与を推測しない。 ## Settingsと編集 @@ -57,5 +59,7 @@ Consoleは会話と実行の履歴を主領域とする。コマンド出力はt - 直接のSubWorkerの履歴を表示している間も、Composerと実行制御の対象は親Workerのままとする。 - OverviewとNormalの切替では見せ方だけを変え、操作対象や権限を変えない。 +- Composerの下書きは、Workerの状態、切断、送信の受付待ちにかかわらず編集できるようにする。編集可能性と送信・実行制御の可否を分け、受付待ちの入力に後続の編集を混ぜない。 +- rewind先は親Workerの会話履歴にあるユーザー入力から選び、適用前に対象を示す。履歴を戻しても、実行済みtoolの外部副作用は取り消されないことを明示する。 - 現在のWorker状態はWorkerの確定したsnapshotから表示し、WebSocketの接続状態や一時的なeventで置き換えない。 - 非公開の推論と生のsystem promptは、debug表示でも公開しない。 diff --git a/docs/report/2026-10-10-repository-key-migration-timestamp-mismatch.md b/docs/report/2026-10-10-repository-key-migration-timestamp-mismatch.md index 3c0f314e1..02029d802 100644 --- a/docs/report/2026-10-10-repository-key-migration-timestamp-mismatch.md +++ b/docs/report/2026-10-10-repository-key-migration-timestamp-mismatch.md @@ -7,7 +7,7 @@ ## 原因 -`crates/workspace-server/src/frozen_repository_key_migration.rs:262–266`のcredential candidate移行は、旧credential ID/revisionから`repository_key_cutover`へLEFT JOINし、`m.fingerprint`を新しいNOT NULL列に挿入する。 +`crates/workspace-server/src/frozen_repository_key_migration.rs:262–266`のcredential candidate移行は、旧credential IDと保存counterの組から`repository_key_cutover`へLEFT JOINし、`m.fingerprint`を新しいNOT NULL列に挿入する。 JOIN条件の`m.created_at <= o.updated_at`が、RFC3339の鍵作成時刻とepoch-millis文字列のWorkdir更新時刻を、そのままTEXTとして比較している。日時としての順序を判定していないため、有効な古い鍵のJOINが外れ、fingerprintがNULLになる。 @@ -30,7 +30,7 @@ JOIN条件の`m.created_at <= o.updated_at`が、RFC3339の鍵作成時刻とepo | 項目 | 値 | | --- | --- | | operation | `call_mgmyCNofdQMZ8uyI5tg0mBhv` | -| credential | `workspace-default`、旧revision 1 | +| credential | `workspace-default`、旧保存counter 1 | | 鍵created_at | `2026-09-16T18:44:56.736Z` | | Workdir updated_at | `1790173821030` | | Workdir updated_atを日時に変換 | `2026-09-23T14:30:21.030Z` | @@ -42,7 +42,7 @@ JOIN条件の`m.created_at <= o.updated_at`が、RFC3339の鍵作成時刻とepo ## 修正方針(この調査では未実装) - frozen migration内で旧データの時刻形式を明示的に読み、同じ時刻型/epoch-millisへ正規化して比較する。文字列比較やSQLの安易なCASTに頼らない。 -- `created_at <= updated_at`の安全確認そのものは削除しない。旧ID/revisionが削除後に再利用された鍵を、過去のcandidateに誤って結び付けることを防ぐ条件である。 +- `created_at <= updated_at`の安全確認そのものは削除しない。旧IDと保存counterが削除後に再利用された鍵を、過去のcandidateに誤って結び付けることを防ぐ条件である。 - candidateだけでなくoperation本体のcredential/host trust等、同じ境界の比較を確認する。 - 正規化不能・不正な時刻や真に必要な証拠が欠落した場合は、NOT NULLエラーになる前に文脈付きで拒否し、transactionをrollbackする。 diff --git a/docs/report/2026-10-10-subworker-cwd-attachment-boundary.md b/docs/report/2026-10-10-subworker-cwd-attachment-boundary.md new file mode 100644 index 000000000..618cbc7d8 --- /dev/null +++ b/docs/report/2026-10-10-subworker-cwd-attachment-boundary.md @@ -0,0 +1,10 @@ +# SubWorker cwd と複数 subtree 分担の障壁 + +T-739 実装時の観測。SubWorkerSpawn の説明では `cwd` は default tool cwd であり、Workdir root や capability を変えない。しかし次の child では、ファイルツールの attachment が cwd subtree を root として扱い、明示した sibling write scope を利用できないと報告された。 + +- `ticket-consumers`: cwd `crates/worker`。worker、client、yoi、manifest の対象ファイルに限定した write scope を委譲したが、通常 Read/Edit の logical path は worker 配下に限定された。committed session の報告 `E01a12680-0113-7552-a54b-f7fabc04506c`。 +- `ticket-api`: cwd `crates/server-api`。server-api、web、resources/prompts を明示的に委譲したが、Web/Prompt/生成出力は attachment 外として未変更。報告 `E01a1267f-7506-7a43-b61e-2cacbe092834`。 + +親は拒否された child に別経路で書かせず、境界内の部分結果だけを確認した。解放後、Client/CLI/Web は個別 cwd と明示 scope の別 child に分け、Prompt/生成同期は親が担当した。予想より細かい分担と再統合が必要になった。 + +改善案: Spawn の documented cwd 契約と file tool logical-root の解決を照合する。cwd が attachment を狭める仕様なら spawn 前に sibling scope を明示的に拒否し、実効 logical root と利用可能 subtree を返す。default cwd と logical root が別なら、その差を Read/Edit のエラーおよび初期 context に表示する。scope 拒否を回避する仕組みの要望ではなく、委譲時に有効な分担を予測できる診断の要望である。 diff --git a/docs/report/2026-10-11-subworker-advisory-surface-gap.md b/docs/report/2026-10-11-subworker-advisory-surface-gap.md new file mode 100644 index 000000000..bbfa5e815 --- /dev/null +++ b/docs/report/2026-10-11-subworker-advisory-surface-gap.md @@ -0,0 +1,23 @@ +# SubWorker実装中の契約接点の伝達 + +## 観測 + +T-741のdomain/store担当とserver認可/router担当を並行委任した。Worker管理の説明では実行中のWorkerへadvisoryを送る`WorkerNotify`が案内されていたが、親に提供されたtool inventoryは`WorkerList` / `WorkerSendInput` / `WorkerStop` / session observationで、`WorkerNotify`はなかった。 + +実装中に、認証済Runtime bindingをwrite transaction内でも照合する必要と、Assign/解除receiptにも同じsource-bound比較が必要という接点が判明した。Running childへの`WorkerSendInput`は使わず、session observationで子の実際の契約・diffを読み、子がIdleへ戻った自然な境界で追加依頼を送った。未完了の子を止めたり、入力の拒否を回避したりはしていない。 + +## 障壁と改善案 + +- 説明が存在しても実際のcapabilityがないため、同じ依存契約の追記をRunning中の担当へ早く伝えられない。双方がコンパイルエラーや作業木から接点を再発見する時間が増える。 +- capabilityがある場合だけ実行中advisoryの説明を提示するか、明示的な親→直接子のadvisory toolを公開する。通常SubmitやIdle-onlyの入力と混同しない。 +- 付与しない場合は、短い契約確定チェックポイントを分担の最初に設けることが現状の回避策になる。これは一般的な自動停止・polling・再起動の許可ではない。 + +本記録はこのSessionのtool提供面の観測であり、Backendの全環境に同機能がないと断定するものではない。 + +## Trusted Reviewer起動時の前提 + +同じSessionで、`builtin:reviewer`と明示MRのreview bindingを指定し、codeをread-only委任して起動を試みたところ、Backendから「writable delegated scopeが必要」、続いて「explicit command grantが必要」と引数検証で拒否された。Tool説明のread-only Reviewerという記述だけでは、この起動前提を予測できなかった。 + +最終的に、code全体のread scopeに加えて専用の空scratch `.review-t741` だけをwrite委任し、commandを明示grantして起動できた。source・Git metadataへのwriteを委任せず、Reviewerの調査・一時出力をscratchに限定した。拒否されたreview authorityを別経路で代替したものではなく、trusted review bindingはBackendが生成してMRに記録した。 + +必要なwrite/commandとcode read-onlyの両立例を、起動前のTool説明に示すと不要な引数試行を避けられる。また、Reviewerの最初の判定提出はseverity `medium`がHTTP 422で拒否され、許容値が`blocker/major/minor/note`と判明した。未記録をMR正本で確認し、新しいreview turnで適切なseverityによる独立判定を提出した。Role側の提出回数制約と引数validation failure後の返却・再開方法も、許容値とともに明示すると扱いやすい。 diff --git a/docs/report/2026-10-12-t748-caller-inventory.md b/docs/report/2026-10-12-t748-caller-inventory.md new file mode 100644 index 000000000..3fe21efba --- /dev/null +++ b/docs/report/2026-10-12-t748-caller-inventory.md @@ -0,0 +1,287 @@ +# T-748: async caller ownership inventory and scoped repair evidence + +## Current status and evidence boundary + +MR `01a12d3f-e90c-7b02-9ed9-cf3bd2e822a1` seq13 requested changes for published +`ebce7ab7b1230654b8eb5340afca8de835f24f12`. The independent review confirmed the +revoke cancellation/publication fix, but found three further direct async callers. +Earlier full suites exercised successful outcomes without proving every composed +caller boundary. Repeatedly treating a helper's apparent purpose as its lane +classification missed Runtime lookup in Ticket projection and post-await reads. +A successful suite is not a substitute for the entry-to-publication inventory. + +The seq13 and adjacent fixes were committed as `5873f250`; embedded protocol +and Feature shutdown fixes as `4d981194`. The remaining confirmed caller-placement +and Worker stop repairs are included in the follow-up below. The read-only audit +children were released after result-observation gaps; that release was not treated +as completion. No audit-to-implementation approval gate is imposed. +No target integration, Ticket conclusion, live update or attachment cleanup is +performed. T743 domain APIs are not copied or implemented here; their owner still +needs normal composition and exact combined-source review. + +The tables and line numbers below preserve the **pre-repair audit snapshot**. +“Unfixed”, “pending”, and “remaining” in those historical sections describe that +snapshot, not the current implementation. The final section supersedes their +placement/stop status and explicitly distinguishes tested boundaries from limits. + +Audited snapshot `server.rs` SHA-256: +`dae05a2649f5d4f884e8118ba4ce6baa902de6e61727c20e97c0afa3661ef3a6`. +Audited snapshot `worker_operations.rs` SHA-256: +`e3ad2327370440fdc40fb2c76f9604b984667e3472570d76145f4d0987d343fd`. +These file hashes identify the inspected implementation, not a provider review ref. + +## Current confirmed ownership changes + +| Entry / composed family | Authority and synchronous dependencies | Current owner / completion boundary | Evidence and limits | +|---|---|---|---| +| `ticket_implementation_cancel` → `scoped_cancel_ticket_implementation` | Ticket authority/assignment reads; Runtime cancel; current-session close; intact assignment cancellation transaction; detail | One accepted finite Host workflow owns cancellation through transaction and response. No Runtime wait on DB, no nested Host admission | Pre-authenticated generated POST route; real separate SQLite writer holds actual cancellation transaction; timer/control progress; cancelled waiter retains actual lease, then one operation/cleared assignment after drain. Saturated Host returns 503 without mutation. Existing live Coder cancellation policy passes | +| `merge_request_selector_repair` → `scoped_repair_merge_request_selector` | Browser auth/explicit confirmation; MR store; current assignment; provider ref; intact repair/audit; public projection | Auth remains an owned DB read; confirmed post-auth mixed stage is one finite Host job | Browser auth is drained before real SQL/shared-mutex contention; post-auth stage stays Pending without blocking Tokio and retains cancelled Host lease. Imported NULL-selector/no-assignment fixture rejects without repair/thread effects. Host overload preserves confirmation priority. Successful provider repair is not claimed by this narrow fixture | +| restore routes → `restore_runtime_worker_with_context` → `project_worker_restore_response_owned` | Existing authorized Host restore returns historical result; then whole stored Worker/links/Workdir/capability projection | Whole independent DB stage. A result with no Worker remains a pure response and needs no DB admission | Real SQL writer/shared-mutex hold; timer and independent Host control progress; bounded overload; cancellation retains actual DB lease; historical state preserved and no registry observation write. This tests post-restore policy, not a complete restore endpoint/E2E | +| Ticket detail/show/query and alias `get_ticket`; edit/event detail and assignment preparation | `TicketAuthority` projection includes open-MR `RuntimeTicketSourceCommitResolver` → synchronous provider HTTP | Mixed stages use finite Host, not sole DB. Subsequent pure assignment mutation stages keep their existing owner | Detail/query held loopback `/v1/repository-refs/observe`; unrelated DB/timer/control progress; cancel retains actual Host budget; overload stays 503. Provider-placement fixture is explicitly unsigned, not proof of signed request authority | +| Ticket tracking/case decision authority | Retained assignment/CAS/body/event checks plus `find_workspace_orchestrator` → live `RuntimeRegistry::worker` | Whole existing decision helper on finite Host; transaction and notification order unchanged | Held loopback live Orchestrator lookup for both tracking/case; independent SQL/timer/control; cancelled actual Host lease retained; failed live authority leaves Ticket unchanged. Existing non-assignee/browser bypass denial passes | +| Browser Ready | Existing mutation/notifications, followed by detail (including source ref observation) | Mutation and detail use one accepted Host job and explicit `execute_ticket_rest_operation_on_host`, never a second Host admission | Fake promptly denies notification GET, holds the subsequent source-ref POST; exactly one Ready event/readiness is durable before held detail; SQL/timer/control progress and cancelled Host ownership persist. Rejected fixture setup/misidentified notification GET are excluded | +| Worker control input/cancel/stop/restore source preflight | Synchronous live source Runtime lookup precedes `resolve_async` and operation admission | `authenticate_worker_mutation_source_owned` admits finite Host; already-owned synchronous callers retain the original helper | Actual control handler's held source GET proves DB/timer/control progress and cancelled finite Host budget; saturation returns availability, not source denial. The fake's lookup fails before an actual Worker cancellation; no product process is used | +| Worker observation capture | Source lookup, initial SQL grant, control lock, live grant re-fence and target Runtime lookup, awaited cold protocol connection | Source on Host; initial grant on DB; actual re-fence/live lookup on Host owns the acquired control guard; cold protocol signing retains existing DB stage | Source/permission/cold-signing contracts remain covered separately. Full capture success/held-protocol composition is not claimed by the new source lookup fixture | + +## Regressions and causal checks + +New `server_residual_execution_tests.rs` contains eight focused tests, including +three read/Ready and two decision-authority table cases. Exact old methods or the +old direct stored-projection placement were restored diagnostically in turn for +cancel, repair, restore, detail, tracking, and control. All six fail at their expected +first-poll/shared-mutex/independent-SQL boundary. Five-second fixture watchdog +release and its induced SQLite BUSY/panic are diagnostic bailout, not progress. +The product source is restored in `finally` after each case. + +Artifacts (local, not tracked product code): +- `target/t748-residual-old-{cancel,repair,restore,detail,tracking,control}-red.log` +- `target/t748-residual-final-restored-green.log`: **26 DB-boundary tests pass** +- `target/t748-residual-decision-policy.log`: non-assignee/browser bypass policy passes +- `target/t748-residual-root-check.log`: root compile closure passes +- `target/t748-residual-provisional-full-suite.log`: original default-parallel Server + **979 passed / 0 failed / 1 existing ignored**, binary11; **178.38 seconds**. + Only the three deliberate executor unknown-outcome panic diagnostics appeared. + No retained test/repro processes were observed after completion. +- `target/t748-residual-provisional-workdir-{default,http}.log`: default153 and + http-client156 tests pass. This does not complete the pending transitive audit. +- fmt/diff and state-contract names **558 / 0 unapproved / 0 stale** pass. + +All Cargo validation uses `target/t748-candidate-build`; no parallelism reduction, +production budget increase, skipped tests or mutation retry was introduced. +Initial test-only private enum/state/DTO mistakes failed compilation; missing +repository seeding and Ready notification GET confusion were diagnosed and fixed. +Those attempts are not semantic proof. The full-suite result above is provisional: +it does **not** conclude the caller inventory or approval. + +## Remaining inventory (not complete) + +`target/t748-async-entry-candidates.tsv` records 661 lexical async-entry candidates +from Server, Worker operations, auth, projection and subscription sources. Visible +lane markers/await counts are discovery hints, not an AST/type-checked call graph +or proof that all transitive operations are safe. Forwarders, actual handlers, +service adapters and callback/background callers must be matched by name and +traced through their real dependencies before marking a family inspected. + +| Inventory work | Current status / next evidence | +|---|---| +| Ticket/MR/Objective/Memory/Subjektiv/Backend Job: canonical/alias/browser/Runtime entries, assignment/routing/decision/projection/publication and callback edges | First committed child result inspected. Major handler owner table returned, but follow-up final result was not observable. Parent released the child and is directly checking remaining adapters/forwarders/launch edges. Macro trait dispatch and representative Objective/Memory/Ticket forwarding plus legacy Memory/cleanup owners inspected; complete per-operation mapping remains open | +| Auth/Runtime/Worker/control/observation/resources/Workdir/repository/Drive/settings/config/catalog/uploads/sessions/subscriptions and lifecycle background edges | First committed child result inspected; parent verified the residual structures below against actual source. The follow-up result was not observable after two Idle notifications; parent released the read-only child and directly traced the remaining four edge groups below. This is not delegated completion evidence | +| Existing pure DB, mixed Host, mandatory Subject callback and accepted publication boundaries | Earlier documented evidence remains relevant; unchanged code does not exempt newly found composed callers from tracing | +| `WorkspaceWorker::stop` first Host result → waiter-owned session close → second Host stage | Parent verified the split in actual source. Structural ownership gap, not yet a cancellation reproducer; tracked below rather than silently marked safe | + +## Additional residuals found before republication (unfixed) + +The first two read-only audit results were inspected through their committed +sessions. The parent then read the concrete handler/helper source listed below. +These are **static execution-placement/continuation findings**, not new successful +endpoint, cancellation or overload tests. None has been repaired or published in +this snapshot. The provisional suite above therefore cannot establish safety for +these families. Lower recovery/transport edges are still being traced by the +parent after releasing the read-only children; this is not independent review. + +`S` below means `crates/workspace-server/src/server.rs` at the frozen hash; +`O` means `crates/workspace-server/src/server/worker_operations.rs`. + +| Entry / family | Entry → authorization → synchronous work → completion | Verified residual and required boundary | +|---|---|---| +| `repository_access_projection`, `repository_ssh_credential_{list,create,generate,get,delete,public_key,rotate}`, `repository_ssh_host_trust_{list,put,get,delete}` | ServerApi entries `S:7879–8107` directly await handlers `S:12934–13129`; owner authentication is awaited first, then active config projection / secret / trust / CAS / audit helpers execute synchronously | Auth ownership does not carry past await. Parent read all handlers and actual `repository_access.rs:391–436` connection reads. Whole existing synchronous operation needs an owner; pure DB and any external provider work must be classified by dependency, not by the SSH label | +| `repository_create` → `scoped_create_repository` | Owner authentication await → validation → `config_store.insert_repository` → intent/replay classification → response, `S:23157–23214` | Insert is outside an accepted job. Preserve the existing intact insert/intent/commit helper and successful replay vs conflicting intent semantics | +| `repository_ssh_connection_probe`, `repository_ssh_host_trust_confirm` | Owner and active binding awaits → catalog / `probe_repository_ssh_connection` → Runtime observation → trust read/CAS/write → response, `S:23247–23358` | Post-auth mixed work has no finite Host owner. Confirm must keep observed host-key selection and fingerprint comparison before the existing trust mutation | +| `worker_session`, `worker_session_history`, `scoped_worker_session_attachment` | Workspace checks → synchronous Runtime Session API → response/body, `S:8696–8769,12011–12069`; registry facade `hosts.rs:2129–2195` | No finite Host admission at these request boundaries. Binding/signing and remote transport dependencies must stay off Tokio and out of the sole DB lane; preserve Session diagnostics and response limits | +| `runtime_worker_attachment_upload` → `upload_runtime_worker_file_contract` | Grant claim → registry resolution → Runtime upload → grant completion or cancellation artifact deletion, `S:28081–28152` | Whole body-upload operation is still direct synchronous mixed work, distinct from the already-owned grant/cancel/delete routes. Admission must precede grant claim; accepted upload must own completion and cancellation cleanup | +| Drive Worker authorization / `drive_grant_create` | `server_drive_grants.rs:54–74,126–166`: source/header checks → `current_worker_identity` or live Runtime worker → later DB grant precheck/mutation | Pre-DB source/live Runtime lookup has no Host owner. `S:18244–18292` confirms `current_worker_identity` directly reaches synchronous `runtime.worker`. Retain no-actor-fallback policy; owner unavailability must not be misreported as authority denial | +| `current_worker_workspace_config_{get,attach,observe,read,commit}` | `S:11625–11699` directly invokes `workspace_config::identity`; `server_workspace_config.rs:65–74` reaches the same synchronous Runtime source helper before later owned DB stages | All five logical-config entries share the unowned identity preflight. Later accepted persistence/publication does not cover this earlier stage | +| `workspace_workdir_list` → `scoped_list_working_directories` | DB job `S:25943–25959` → summaries `32974–32999` → all Runtime workdir observations `33515–33524` → list/detail Runtime calls and SQL reconciliation `33421–33467` | Mixed Runtime/SQL helper is incorrectly on the sole DB worker. Preserve live reconciliation and diagnostics, but move the whole mixed stage to finite Host rather than pretending the list is stored-only | +| `workspace_config_tree_commit` → `scoped_commit_workspace_config_tree` | DB job `S:13444–13460` → prepare → repository-access projection → config commit → Runtime prompt publication, `13498–13517`; remote publication `hosts.rs:5090–5099` | SQL commit is followed by synchronous network publication inside sole DB job. Keep commit-before-best-effort-publication and accepted ownership; do not leave the continuation on the waiter. Logical config's separate owned Host dispatch is not this UI path | +| `repository_{list,detail,log}` and aliases | DB jobs `S:29108–29183` → live repository reader → summary/log provider; `repositories.rs:276–375` invokes Git subprocess for materialized local repositories | Provider work is not pure DB. Mixed catalog/provider work needs finite Host, without changing existing diagnostics for unmaterialized sources | +| `worker_control_stop`, `runtime_worker_stop`, `runtime_worker_stop_alias` → `WorkspaceWorker::stop` | `O:379–425`: grant/lock/re-fence → Host #1 Runtime stop returns result/control guard → waiter session lock/close → Host #2 linked Workdir observation/SQL | Structural ownership gap confirmed by parent: accepted Runtime stop has no owner for session close and subsequent Workdir synchronization if its waiter vanishes. `S:18609–18619` awaits real session close; the second Host admission also occurs after side effects. Whole accepted stop must own this continuation without nested Host acquisition. Cancellation reproduction remains pending | + +### Parent takeover of the four remaining Runtime edge groups + +The Runtime child's first result remains useful, but its follow-up final result +was not available through authorized observation. The parent did not infer +completion from Idle and did not loop Submit or inspect raw session storage. +See `docs/report/2026-10-12-worker-observation-followup-gap.md` for the bounded +observations and handling. The parent read the following actual source directly: + +| Edge | Source and actual stages | Classification / unresolved contract | +|---|---|---| +| Embedded protocol connect and method pump | `S:31845–31902` directly calls synchronous `attach_worker_protocol_scoped` before spawning its async pump; the pump directly calls `send_connected_protocol_method_scoped`. `worker-runtime/src/runtime.rs:3486–3598` takes synchronous operation/state locks; Shutdown performs `begin_stop_operation` and `commit_stopped_worker` (`4359–4445`), with durable record/snapshot writes in fs-store builds (`5256–5269`) | **Additional confirmed placement residual**: Backend async connect/pump is not an accepted finite Host owner. The existing endpoint identity/settled-operation checks and commit/publication helpers must remain intact; no Runtime-store refactor is needed | +| Workdir creation reservation → Runtime result → finalization | `S:26316–26609`: DB preparation/reservation, Host runtime selection/observation/create, then waiter-admitted failure or final registry/receipt DB jobs. Same-id reentry observes the reserved Runtime Workdir before attempting create (`26473–26517`) | **Confirmed continuation split**, with durable same-operation recovery intent, not automatic mutation retry. Existing recovery does not make the original create job own finalization after its waiter disappears. Final classification/regression must cover accepted Runtime outcome, final receipt, cancellation and overload without creating a second provider operation | +| External grant fence → attachment/provider cleanup | `S:25595–25709`: DB authority/read or revoke/fence/audit, then waiter-owned cleanup/provider stop; terminal GET/revoke explicitly retries cleanup. Cleanup itself commits pending, awaits release, then admits completion/audit DB (`25719–25799`). Expiry task retains its async owner and retries (`24790–24854`) | **Confirmed continuation split plus explicit recovery**, not a claim that revocation failed or is unsafe after its DB fence. Grants remain denied by their committed fence. Need causal cancellation/lease evidence for cleanup and preserved retry/expiry policy before choosing the smallest ownership fix | +| Command provider acceptance → registration/audit | `S:19507–19631`: Host identity, DB attachment fence, async session/provider, then synchronous command-handle registration. External `operate` admits audit-requested DB, sends provider request with explicit cancel guard, receives result, then admits audit-completed DB (`888–1024`); External command-handle registration follows another await (`1372–1428`). Local command start retains Runtime resources in its actual task and owns terminal-close admission (`workdir/src/local.rs:1831–1897`) | **Partially protected / continuation question**. SQL jobs are owned and local actual command resources are retained; explicit external cancel is not SQL retry. Provider acknowledgement-to-registration can cross a cancellable audit await. Do not assert an orphan process or whole command success without a focused protocol/registration reproducer; do not rewrite external cancellation semantics speculatively | +| Catalog and single-workspace shutdown | `S:12222–12279`: catalog waits for HTTP serve, drains Worker projection and owns Feature shutdown DB (`2651–2664`); single-workspace `serve` aborts its hook but calls synchronous Feature shutdown directly. `feature-storage/src/lib.rs:220–241,521–523` fences and waits on connection close. Executor `drain` (`db_executor.rs:290–315`) covers only jobs accepted before its first poll, not later submissions | **Additional confirmed direct shutdown placement residual** in single-workspace serve. Separately, catalog does not demonstrate hook/expiry/Host/cleanup producer quiescence before storage shutdown. Whole producer/drain ordering remains a lifecycle question; no live shutdown was invoked to test it | + +Domain generated adapters, remaining Tool/WIP forwarding, Backend Job / legacy +Memory launch lower edges remain under follow-up inspection. Existing domain SQL +helpers are not being reimplemented or globally refactored; ownership tracing is +separate from re-auditing every SQL semantic branch. None of the additional rows +above has new executable regression evidence yet. + + +T743's additional assignment page/diagnostics/answer APIs are absent from this +source. Ticket seq8–9 identifies their separate owner's normal composition with +the published T748 facade. This inventory is **not** an attestation of that future +combined source; no duplicate API or combined approval is introduced here. + +## Republication conditions + +Review-ready requires a consolidated caller-name list with each entry's auth, +SQL/provider/Runtime stage, post-await projection, post-commit publication and +owner identified; confirmed residuals repaired with focused causal regressions; +remaining uncertainty explicit; and final root/changed-crate/affected-feature, +format/diff/name checks on that final source. Only then normal same-selector push, +provider exact-HEAD verification and fresh trusted independent review of this MR. +No new Reviewer Runtime or separate Coder is created. Read-only audit children do +not grant review verdict or integration authority. + +## Scoped caller repair and validation history + +The same Ticket branch is retained for advancing the existing MR. This section +records validation history, not approval or integration. The final-validation +section below supersedes the intervening failed runs without deleting their evidence. +The existing domain helpers, +authorization fences, receipt/replay rules, and transaction/publication order +remain intact. No automatic mutation retry, production limit increase, nested +Host admission, or test parallelism reduction was introduced. + +| Repaired family | Current owner / executable evidence | +|---|---| +| Repository access/SSH credential and host-trust CRUD, Repository registration | Whole post-owner-auth synchronous operation on DB. Registration regression observes the **config store's actual connection** waiting behind a separate SQLite writer, then cancels the waiter; after drain exactly one Repository exists and the same registration intent returns HTTP200/replayed without another row | +| SSH probe/host-trust confirm | Whole post-owner/binding-auth mixed probe/observed-key selection/fingerprint comparison/trust mutation on Host. Existing narrow SSH/trust policy tests pass in the full Server suite; no successful real SSH connection or host-key exchange is claimed | +| Repository list/detail/log and aliases | Live Git/catalog helpers on Host, not DB. With DB admission saturated, empty catalog and absent-key detail/log retain their ordinary success/404 responses. This regression proves lane selection, not held Git subprocess behavior | +| Worker Session/history/attachment | Typed Session service and attachment handler admit Host before synchronous Runtime observation. Loopback held Runtime lookup proves independent SQL/timer/control progress, cancelled actual lease retention, bounded overload and release after actual drain | +| Body upload | Host admission precedes the upload grant claim and owns resolution/upload/completion/cancellation cleanup. The same loopback table holds the Runtime lookup after claim; cancelled waiter leaves actual owner, and provider refusal completes the claim back to Pending. Successful artifact upload/delete and established remote sessions are not exercised by this refusal fixture | +| Drive Worker authorization / grant create; all five logical-config identity entries | Live source/target preflight uses Host; later DB authorization and mutations retain their previous transaction owner. Three held-lookup table cases prove independent SQL/timer/control, cancelled lease retention, and typed HTTP503 availability (not false authority denial) when Host admission is full. Existing no-actor-fallback and source-fence policies remain in the suite | +| Workdir list | Whole live Runtime reconciliation/SQL helper on Host. Held Runtime list lookup in the Session/Workdir/upload table proves sole DB remains free and cancellation retains the actual Host budget | +| UI config commit | Whole existing evaluate/project/commit/best-effort prompt publication helper on Host. Loopback held prompt POST confirms the changed tree is already durable while independent DB/timer/control proceeds. Cancelling the waiter retains publication ownership; refused publication neither repeats nor rolls back the commit | +| Worker stop canonical/alias/control entries | One accepted Host workflow owns Runtime stop, session fence/close, stored read and linked Workdir synchronization. An in-process Runtime emits its stop receipt while a real session fence is held; after waiter abort and fence release the registered session is removed. Restoring the exact old stop method makes this test fail at the missing cleanup assertion; restoring the fix passes. No real Worker/product process is spawned or stopped. Linked Workdir provider success is not claimed by this session-only fixture | +| Embedded protocol connect/method; single-workspace Feature shutdown | `4d981194` owns these operations on the existing finite Host/DB lanes. Focused protocol and actual Feature connection shutdown regressions remain green in the final suite | + +Final logs in the Workdir (build artifacts, not committed product content): +- `target/t748-stop-old-placement-red.log`: old stop fails **missing cleanup**, not timeout. +- `target/t748-stop-restored-green.log`: restored accepted-stop ownership passes. +- `target/t748-caller-host-green.log`: Session/history/attachment/Workdir/upload table passes. +- `target/t748-repository-create-green.log`: actual SQLite commit/cancel/replay passes. +- `target/t748-drive-config-preflight-green.log`: all three live authority cases pass. +- `target/t748-ui-prompt-owner-green.log`: commit-before-publication passes. +- `target/t748-repository-provider-green.log`: all three Repository read lanes pass. +- `target/t748-caller-final-root-check.log`: workspace-root `cargo check` passes. +- `target/t748-caller-final-server-suite.log`: original default-parallel Server + **988 passed, 0 failed, 1 existing ignored**, plus **11 binary tests**, 185.38s. +- `target/t748-caller-final-workdir-default.log`: **153** tests pass across targets. +- `target/t748-caller-final-workdir-http.log`: **156** tests pass across targets. +- fmt/diff checks pass; `target/t748-caller-final-names.log`: **558 / 0 unapproved / 0 stale**. + +Test setup errors are excluded from semantic evidence: the first stop fixture +omitted the Runtime's live Worker summary; the Repository busy observer initially +watched the general store rather than the config store's distinct connection. +Both were corrected before the successful runs. Missing package/Python command +and compile-time DTO/Duration/Drive-error mistakes also prove no product behavior. + +### Explicit limits, not an expanding audit gate + +The four lower-edge rows above separate confirmed direct placement repairs from +Workdir create / external grant cleanup / provider acknowledgement continuation +questions and catalog producer quiescence. The latter are not redesigned here, +per the user's instruction not to expand speculative Workdir/grant or unrelated +Feature work indefinitely. Their existing durable recovery, explicit cleanup +retry and provider cancellation policy are preserved, not reclassified as +end-to-end cancellation proof. This report is not a mechanically complete +transitive call graph or an assertion that every future/composed caller is safe. +T743's separate new API composition still requires its owner's exact combined +source validation/review. None of these limits constitutes Ticket conclusion, +MR approval, target integration or permission to update the running environment. + +### Post-suite focused validation blocker + +After the successful full suite above, the current candidate was checked with +`cargo test -p yoi-workspace-server --lib db_execution_tests` using the same +candidate target directory and ordinary parallel execution. Result: **31 passed / +1 failed**, in 8.06s. Failure: +`cancelled_runtime_key_revoke_commits_audit_and_removes_broker_registration` +reported the shared `observed` helper's two-second `Elapsed(())` timeout, without +identifying which of its several lifecycle waits timed out. Source inspection +confirms projection shutdown is already present in this fixture; it is not +legitimate to attribute the failure to omitted projection shutdown, treat it as +load-only, increase limits, reduce parallelism, or rerun until green. + +Evidence is preserved in `target/t748-caller-final-boundaries.log`. The prior +full-suite success is real but insufficient to declare final validation complete. +The new caller and stop cases all passed in this focused run. Next diagnosis must +identify the exact wait and inspect actual HTTP/job/broker ownership events before +choosing a fixture or product repair. No normal push, fresh review, approval, +integration or Ticket conclusion was performed for this candidate. + +## Continued diagnosis and final validation + +Returning the failed test as a stop condition was a workflow mistake, not an +external outage, lost authority, or additional audit approval gate. Work continued +on the same branch. The failed run above is retained as evidence. + +The `observed` helper now reports its caller location. A diagnostic 32-case run +passed; that result alone was **not** treated as a diagnosis or used to erase the +failure. Source tracing identified an uncontrolled cold broker signing producer +in the revoke fixture. The test now receives the actual signed WebSocket +handshake and drains its completed DB work **before** holding the revoke writer. +The handshake remains pending, so broker shutdown is still tested. An early HTTP +response is selected against actual SQLite entry, reporting status/body rather +than waiting for an event that can no longer occur. The original failed run did +not identify its exact wait; attributing it conclusively to signing would exceed +the captured evidence. + +Subsequent full runs exposed the Ready table's distinct failure **before** source +POST arrival (caller location, not timer/control failure). Artifacts: +`target/t748-post-fixture-server-suite.log` and +`target/t748-final-framing-server-suite.log`. The HTTP fake previously assumed +one TCP read contained the request line and answered without consuming a body. +It now parses the complete header/body; a deterministic two-byte-fragment reader +proves complete frame consumption and truncated-body rejection. This is a real +fixture flaw but does not by itself establish the historical timeout cause. + +Ready setup includes mutation and best-effort notification before the source +POST. The held-provider arrival now uses the existing five-second fixture +watchdog as its setup bound, selected against handler completion. The subsequent +**timer and independent SQL/control observations retain their two-second budget**; +actual held-request cancellation/lease/overload/cleanup assertions are unchanged. +Production admission limits, normal test parallelism, and provider timeout are +unchanged. This setup-bound change is explicit, not a hidden limit increase or +claim that Ready completes within two seconds under a whole-suite workload. +Restoring the exact published old Ready method still fails this revised test +(`target/t748-ready-old-boundary-red.log`, Ready returns only after synchronous +provider wait); restored code passes, so the offload distinction is not weakened. + +Final code validation after these fixture changes: +- `target/t748-boundary-setup-fixed-green.log`: **33 DB-boundary cases pass**. +- `target/t748-final-setup-root-check.log`: root compile closure passes. +- `target/t748-final-setup-server-suite.log`: original default-parallel Server + **989 passed / 0 failed / 1 existing ignored**, **11 binary tests**, 189.66s. +- `target/t748-final-setup-workdir-default.log`: **153 pass** across targets. +- `target/t748-final-setup-workdir-http.log`: **156 pass** across targets. +- `cargo fmt --all -- --check`, `git diff --check HEAD`, and state-contract names + **558 / 0 unapproved / 0 stale** pass. + +No failed semantic run was merely repeated until green: the intervening source +changes and the limits of each diagnosis are recorded above. These results support +publication for fresh exact-source independent review, not reviewer approval, +MR integration, Ticket conclusion, or updates to the live dogfooding environment. diff --git a/docs/report/2026-10-12-t748-suite-diagnosis.md b/docs/report/2026-10-12-t748-suite-diagnosis.md new file mode 100644 index 000000000..ed594bde6 --- /dev/null +++ b/docs/report/2026-10-12-t748-suite-diagnosis.md @@ -0,0 +1,444 @@ +# T-748: default-parallel suite failure diagnosis + +## Sources and preserved observations + +- Original local candidate: `61533fadb6b39465af60cb55aed2b4d0650319cd`. +- Comparison source: `56795fb92cb9def9bcd701bd51d92e99526f8eff` (develop/T742). +- Same implementation branch: `work/T-748-bounded-db-execution`. +- The original `target/t748-server-tests.log` remains untouched. Its 97 FAILED + lines and 600-second timeout were symptoms, not proof of one cause. +- Initial diagnosis used `cargo test -p yoi-workspace-server -- --nocapture`, + default libtest parallelism, without changing queue limits, features, or startup + settings. `target/t748-diagnostic-environment.txt` records the environment; + this host reports 24 available CPUs. Diagnostic captures were bounded to + 90/150 seconds to obtain errors before the original timeout, not counted as + successful full-suite runs. + +Logs under `target/` are local diagnostic artifacts, not tracked review inputs. +This report records their meaning and the permanent regressions that reproduce it. + +## 1. Different fixtures consumed one global Host admission budget + +The first non-intentional errors in `target/t748-server-nocapture.log` were +`DbCapacityExceeded` in manual Worker setup (`server.rs` at the then-current +line 50614), followed by assignment fixture construction (52990/53045) and +HTTP 503 responses. The deliberate before/after-commit executor panic tests +are different: those passed and verified unknown-outcome diagnostics. + +Temporary submit/start/cleanup instrumentation recorded submitting test names, +closure types, actual job IDs and the execution-owner identity. It showed a +single Host lane with `max=18`, with accepted jobs from unrelated fixtures. +A rejection snapshot contained `{32,45,46,...,61}`. The complete diagnostic +capture later contained cleanup for every submitted Host job on that owner; +it did not show permanently abandoned accepted Host jobs. Instrumentation was +removed before the source was committed/published. + +`target/t748-live-process-18.txt` sampled the test process and threads at the +actual rejection. That sample had 126 threads; the Host workers were runnable, +and the process was using approximately 17 CPU cores. There was no captured +thread-creation EAGAIN/unavailable error. This evidence does not establish an +OS thread/CPU hard limit as the cause. No test processes remained after the +bounded diagnostic run or the completed suite. + +A representative failure passed alone (0.56 seconds); the eight logging cases +passed with four test threads (2.46 seconds). The isolated comparison source's +full default-parallel suite passed: 931 library tests, one pre-existing ignored +migration fixture, 11 binary tests; library duration 168.74 seconds. This did +not prove that all new failures were capacity-related, but did distinguish the +new shared-admission rejection from an observed baseline failure. + +### Ownership fix (`f3d4aba5`) + +Production budgets remain shared and finite: DB 1 worker/32 queued, Host 2/16, +cleanup 2/16. Ordinary fixtures now explicitly own a finite DB executor and lazy +Host/cleanup owners at those same limits. API clones, child Workspace APIs, and +weak retention adapters use their injected owner. No global fixture lane cache, +parallelism reduction, unlimited queue, or capacity-wait retry was added. + +The permanent saturated-Host regression fills both running slots and all 16 +queued slots, cancels waiters, advances paused Tokio time, and still observes +rejection until actual completion. Another fixture and the separate cleanup lane +can progress; draining restores the original budget and completes exactly 18 +accepted jobs. Existing real-SQLite cancellation/lease/drain tests remain active. + +## 2. SQL offload invalidated fixture assumptions about first poll and publication + +After Host isolation the default-parallel suite finished in 171.57 seconds: +954 passed, three failed, one existing ignored. Actual errors were: + +- removal subscriber saw a legitimate queued Runtime stop Upsert before Removed; +- external session-open expiry exceeded its one-second bound while its DB lane + was still shared across other fixtures; +- grant replacement returned success because one initial poll had only submitted + the offloaded authority read, not reached the held original-grant lock. + +The ordinary DB fixtures were isolated too. The one-second expiry assertion was +not increased. Grant testing now drains its actual authority read then polls the +saved response to reach the held lock before replacing the grant. Removal testing +observes the committed stopped projection before taking the removal baseline. +The Removed payload assertion is unchanged. These are observable synchronization, +not sleeps or retries. Focused expiry, grant, and removal tests all passed. + +A captured cleanup job also attempted to poll a Tokio timer after its fixture +runtime had shut down. The terminal checker fixture now retains that original +runtime through accepted Host and cleanup jobs, then stops projection producers +and drains actual DB work before ending. Terminal SQL state is not treated as +actual cleanup completion. The subsequent full run contained only the three +intentional executor panic diagnostics, not the shutdown-context panic. + +The synchronization/teardown changes are in `df9d056d`. + +## 3. Restore intent's DEFERRED read-to-write upgrade returned SQLite BUSY + +The next default-parallel run finished in 171.48 seconds with 956 passed, two +failed, one existing ignored. Both failures were SQLite `DatabaseBusy`, extended +code 5, not capacity rejection: + +- production-profile zero-input Orchestrator restore; +- pending manual Worker claim restore reconciliation (500 instead of 409). + +Both failed alone too. `target/t748-busy-origin.log` captured the error and a +backtrace through `pin_internal_worker_restore_intent` -> the transaction helper +-> internal restore -> manual claim. The journal read authority first in a +DEFERRED transaction while the offloaded projection's other SQLite connection +could write. Such an upgrade can return BUSY without invoking SQLite's busy +handler; treating this as a retryable whole mutation would be incorrect. + +A new real-SQLite regression holds an IMMEDIATE writer on another connection, +registers an owned per-connection busy observer, and attempts one journal pin. +The old helper fails deterministically with BUSY/5 before waiting for ownership +(`target/t748-deferred-upgrade-red.log`, 0.23 seconds). The fixed helper waits +before its first authority read, while a current-thread timer and control task +progress. After releasing the writer it persists exactly one requested tuple; +there is no application-level mutation retry. + +`f597f1b5` makes this existing mutation helper explicitly +`with_immediate_transaction`. Restore journal and deletion authority reads, +mutation and commit remain in one transaction. Focused DB/HTTP tests (8), the +original restore and claim cases, and deletion tests (13) passed. + +## 4. Why a pre-dispatch failure could become the 600-second suite timeout + +The original long-running logging case joined an HTTP request with a fake provider +waiting for command dispatch. On pre-dispatch rejection the HTTP future can finish +with 503 while the receiver remains Pending: its channel is still owned by the +fixture, but no command will arrive. An unbounded `join!` waits forever in that +state and hides the actual error behind the outer suite timeout. + +The permanent `saturated_host_before_command_start_returns_availability_without_a_provider_operation` +regression uses the real signed HTTP route with a controlled full Host budget. It +observes the final 503/`db_capacity_exceeded` response and the still-live Pending +provider receiver, with no process or command dispatch. The original logging +fixture now asserts an early HTTP response before waiting for both branches, so +an unexpected rejection reports its body immediately rather than hanging. This +reproduces the causal timeout mechanism; the old log itself has no stack trace +from the hung provider future. + +The nine logging cases pass together at default parallelism (`49bfbfb2`). + +## Artifact/source isolation during comparison + +Initially the archived baseline used the existing Cargo target directory. That +replaced a same-package artifact/fingerprint; one later post-fix representative +command ran the baseline's 932-test binary (and `db_executor --list` had no tests). +That result was rejected as candidate evidence. All later candidate checks/tests +use `--target-dir target/t748-candidate-build`, separate from the baseline cache. +This changes artifact location, not test parallelism, features or admission policy. +The fresh candidate build listed the expected executor tests and total test count. + +## Validation and remaining composition boundary + +Pre-review code source `49bfbfb2` passed the full default-parallel Server suite, +including the command-overload case: 960 passed, zero failures, one existing +ignored migration fixture; 11 binary tests, library duration 175.29 seconds. +The only captured panics were the three deliberate unknown-outcome tests. +Root `cargo check` compile closure passed (including TUI/Server), as did Workdir +default (153 tests) and `http-client` (156 tests) full suites, format/diff checks +and state-contract names (558 matches, zero unapproved, zero stale exceptions). +These commands used the isolated candidate artifact directory; libtest default +parallelism and the production budgets were unchanged. The MR records the exact +published source, which also includes this report and the boundary document. + +## Post-publication independent review and causal fixes + +MR `01a12d3f-e90c-7b02-9ed9-cf3bd2e822a1` seq3 independently reviewed published +source `66bb9d0b` and requested changes despite the successful original suite. +Its two major findings exposed boundaries the earlier tests did not prove: + +- `T748-DB-NETWORK-LANE`: mixed Subjektiv helpers could wait for synchronous + Runtime HTTP on the sole DB worker. Fix `d3fcfb29` places session read/search, + subject projection, memory staging, and legacy consolidation-scope helpers on + the existing finite Host owner; pure DB-resident operations keep DB placement. + Whole transaction helpers remain intact, and no Runtime I/O is introduced + inside a SQL transaction. +- `T748-ASYNC-PROTOCOL-SIGNING`: cold protocol source lookup called live binding + checks and signing directly on Tokio. Fix `c0223a0d` awaits the owned DB stage. + The source API now propagates failure explicitly: an invalid live binding or + signing/admission failure cannot be hidden as `None` or a legacy bearer fallback. + +Permanent real loopback HTTP regressions hold upstream Runtime replies during +session read/search, subject and legacy-scope requests, while independently +observing SQL, timer and control progress. Cancelling waiters retains the actual +Host lifecycle lease until completion. Cold protocol source tests control another +SQLite writer and shared connection mutex wait, and verify bounded overload, +fresh signing and revocation without fallback. Existing queue/cancellation and +deletion evidence now observes the actual mutation owner; the mixed fixtures +explicitly inject a small finite Host budget. Production budgets are unchanged. + +Focused boundary tests passed (10). Restoring the old placement produced a +failing regression, and fixed placement passed; an earlier syntax-rejected +instrumentation attempt is excluded from evidence. Diagnostic artifacts under +`target/` are not production source or replacement domain implementations. + +Exact code source `c0223a0d` then passed root `cargo check` and the original +**default-parallel** Server suite: **962 passed, zero failed, one pre-existing +ignored**, 11 binary tests; library duration **173.36 seconds**. Only the three +intentional executor panic diagnostics appeared, with no residual test processes. +Workdir default (153) and `http-client` (156) full suites, format/diff checks and +state-contract naming (558 matching lines, zero unapproved, zero stale) passed. +All used the isolated `target/t748-candidate-build` artifact directory. Logs: +`target/t748-review-fix-{root-check,full-suite,workdir-default,workdir-http,format,names}.log`. +The documentation-only publication `b10b7315` did not change that tested code. +MR seq5 independently confirmed the two old fixes and repeated the checks above, +but requested changes for a new mandatory-callback cycle; see the next section. + +## Mandatory callback must be independent of its waiting Host parent + +MR seq5 finding `T748-HOST-CALLBACK-CYCLE` traced Host create/restore -> Runtime +bootstrap -> mandatory Session attribution -> the same finite Host lane. Two +waiting parents could consume both workers while successfully admitted callbacks +could not run. Cancelling a waiter correctly retains the actual parent, so it is +not a solution. The Reviewer's compiled executor artifact proved this placement +cycle, not a complete create endpoint/E2E reproduction. + +Fix `a2487b57` separates store-only scope discovery into typed Subject, current +Job consolidation and legacy consolidation authority. Session attribution accepts +only Subject authority and executes its whole existing SQL operation on the +independent DB lane. It rejects consolidation roles without calling Runtime. +Mixed read/search, subject projections and allowed legacy consolidation operations +keep their Runtime checks on Host; the previous DB/network fix is not undone. + +The same dependency audit identified Subject resident context reads used by +Runtime bootstrap/restore. All Subject-only memory operations use store-only +Subject authority and DB ownership too (resident summary/context, proposal +validation, receipt lookup, explicit staging). Other memory operations retain +Host ownership because legacy authority can require synchronous Runtime lookup. +No transaction is split across an await and no production budget is increased. + +The strengthened signed route regression holds both actual production-sized Host +workers (2/16) in `run_host_workflow` parents and retains their create admission +leases until callbacks finish. Resident and attribution routes authenticate real +Runtime source proofs, check current singleton ownership, respond successfully, +and persist both distinct Subject session attributions **before** either parent +releases capacity. It also preserves Session callback reentry during deletion +drain. Runtime's wait is modeled at this policy boundary; no product process or +complete create endpoint is spawned. Separate denial coverage proves legacy +consolidation callbacks return 403 without Runtime lookup, leave no attribution, +and do not bypass authentication (401). + +Only Session attribution is an allowed deletion-drain callback; that endpoint +allowlist was not expanded. An initial fixture sent resident reads during deletion +and correctly received 409. The fixture now tests those reads before deletion, +then Session attribution while deletion waits. This is alignment with the existing +fence, not a relaxed product gate or success-picking retry. A diagnostic Python +invocation was unavailable before making any source change; only the subsequent +Deno lane-restoration runs are causal red evidence. + +Restoring just Session's old Host placement fails at its expected callback +watchdog; restoring just resident memory's old Host placement fails at its own +watchdog. Fixed placement succeeds without parent bailout. Evidence: +`target/t748-callback-old-{session,resident}-placement-red.log`, +`target/t748-callback-restored-green.log`. Final focused signed callback tests (5), +DB boundaries (10), and Subjektiv regressions (25) passed. + +Exact code `a2487b575df1f348251214c53da61595860fcb40` passed root `cargo check` and +original default-parallel Server **963 passed / 0 failed / 1 existing ignored**, +11 binary tests; library duration **173.01 seconds**. Only the three intentional +executor panics appeared, with no residual test processes. Workdir default153 / +http-client156, fmt/diff and naming558/0/0 passed. The isolated candidate cache +remains in use. Logs: `target/t748-callback-{root-check,full-suite,workdir-default,workdir-http}.log`. +The docs-only publication `27b6c688` changed no tested code. MR seq7 independently +confirmed the callback fix and earlier fixes, but identified another direct async +caller; its independent verdict was `request_changes`, not approval. + +## Worker discovery and adjacent metadata caller audit + +MR seq7 major `T748-ASYNC-WORKER-DISCOVERY` traced async discovery directly into +synchronous `workers_response`. That helper acquires the shared SQL connection +for the Worker projection snapshot, Workdir registry/links and authority projection. +The Reviewer's independently compiled public snapshot helper reproduced a +300.246ms shared-mutex stall while its Tokio timer could not progress. This was +helper-boundary evidence, not an authenticated endpoint/E2E run. + +Fix `dbcea6c3` awaits the **whole existing** `workers_response` on the independent +bounded DB owner. Source-proof requirement, Workspace/query/cursor validation, +visibility filtering, pagination and control-grant behavior are unchanged. The +other production callers were traced: Worker list/resource detail already use DB; +mixed Runtime cleanup plans use Host. No network work is added to the DB owner. + +The adjacent metadata audit found async `workspace_runtime_resources_response` +also directly calling synchronous Runtime summaries (including remote signed HTTP +and its awaited DB authorization). This call now uses finite Host ownership, +then the existing independent async binding read. Runtime list/detail share this +boundary. Whole SQL helpers, admission budgets and authentication fallback rules +are not changed. + +The permanent post-auth discovery handler regression uses a separate SQLite +IMMEDIATE writer and busy observer to hold the actual shared connection mutex. +While the accepted discovery read is queued, current-thread timer and independent +Host control progress. A second read receives 503/`db_capacity_exceeded`; a source +without proof receives 403 before admission. Cancellation retains the actual +queued read's lease/budget until result cleanup; ordinary completion preserves the +empty filtered catalog response. Signed route proof verification remains covered +by the existing dedicated-permission test, separately from this post-auth policy +boundary so middleware's own DB wait cannot hide the handler bug. + +Restoring the exact old discovery call fails its first-poll assertion after the +fixture watchdog releases the synchronous mutex wait. Restoring only the old +Runtime-summary placement fails because Tokio cannot observe the real loopback +request until the upstream watchdog releases it. Fixed summary placement lets +SQL/timer/control progress during the held signed upstream request and keeps the +actual Host lease after cancellation. Explicit upstream-release observation +prevents watchdog failure cleanup from masquerading as progress. Logs: +`target/t748-discovery-old-placement-red.log`, +`target/t748-runtime-summary-old-placement-red.log`, +`target/t748-discovery-summary-fixed-boundaries.log` (12 passed), +`target/t748-discovery-summary-policy.log` (4), and callbacks (5). + +Exact code `dbcea6c34ba55453ce588d68272df238cde19f47` passed root `cargo check` and +original default-parallel Server **965 passed / 0 failed / 1 existing ignored**, +11 binary tests; library duration **175.00 seconds**. Only three deliberate +executor panics appeared, with no residual test processes. Workdir default153 / +http-client156 and fmt/diff/naming558/0/0 passed. Logs use the same isolated +candidate cache: `target/t748-discovery-summary-{root-check,full-suite,workdir-default,workdir-http}.log`. +Docs-only publication `b63bdb44` changed no tested code. MR seq9 independently +confirmed these fixes, then requested changes for Runtime management's residual +synchronous authority calls; it was not an approval of the complete source. + +## Runtime management: authority, network, and accepted publication ownership + +Seq9 major `T748-ASYNC-RUNTIME-MANAGEMENT` identified registration's direct +`get_validated` signing identity read and post-ping direct live-binding fence. +The Reviewer reproduced both exact compiled helpers waiting approximately 300ms +on the shared SQL mutex with no Tokio timer progress (helper-boundary, not endpoint +or E2E proof). Registration's synchronous Runtime Worker scan also requires a +network/authority boundary; ping previously used an unbounded blocking admission. + +Fix `925bce2a` introduces whole owned DB stages for validated active signing +identity and the post-ping current binding fence. Runtime Worker scans and ping +use existing finite Host admission. A changed/revoked binding still produces +conflict rather than attesting the earlier connection; owner/key/CAS validation +and existing whole store transactions are retained. + +The adjacent management audit also closes cancellation separation between SQL +commit and local Runtime publication. Registration and metadata updates now own +mutation, expectations/registry/subscription publication, and response observation +in one accepted Host workflow, with their SQL helper awaited on the independent +DB owner. Public Runtime resource/detail helpers admit ownership; the already-owned +workflows call explicit `_on_host` variants directly instead of reacquiring the +same lane. There is no Runtime/network await inside a SQL transaction or nested +Host admission. A cancelled waiter cannot strand a committed metadata change +without publishing the matching binding. + +Launch options were another mixed Runtime summary/config helper placed on sole +DB. Its signed Runtime authorization could enqueue DB work behind its own waiting +parent. It now uses Host, preserving SQL/control progress and finite ownership. +The audit traced remaining signing validation/binding-fence calls: UI signing +operations already use DB, Runtime gate calls occur on Host, and removal already +has an owned Host workflow. DNS validation stays async; pure local registration +construction/notification stays in the accepted publication owner. + +Permanent regressions cover both authority stages under a real separate SQLite +IMMEDIATE writer and shared-mutex busy observer: current-thread timer/control +progress, overload503, cancelled actual DB lease retention/cleanup, positive +validation and revoked binding409. An actual signed loopback ping is held while +SQL contention starts; after its reply the real handler's post-ping fence queues +without blocking Tokio or Host control. Another signed loopback scan proves SQL/ +timer progress and retained cancelled finite Host admission. The metadata mutation +case cancels while its own SQL is waiting, then observes one durable update and +an equal published binding after actual Host/DB drain. Producers are stopped and +drained while the original fixture runtime remains alive. + +Old identity, binding, scan and launch placements each fail their expected +contention/watchdog boundary; fixed code passes. Logs: +`target/t748-management-old-{identity,binding,scan,launch}-placement-red.log`, +`target/t748-management-final-restored-green.log` (16 boundaries), registration +policy11 and connection policy13. A zero-match launch-options exploration is not +counted as proof; its real held-summary case is in the 16 boundaries. The first +fixture teardown named an unavailable method and failed compilation; it was +corrected to the existing `shutdown_async`, not counted as a semantic test run. + +Exact code `925bce2a405b6d3255887fba88867ab8d23882dc` passed root `cargo check` and +original default-parallel Server **969 passed / 0 failed / 1 existing ignored**, +11 binary tests; library duration **172.25 seconds**. Only three deliberate +executor panics appeared, with no residual test processes. Workdir default153 / +http-client156, fmt/diff/naming558/0/0 passed. Same isolated cache; evidence logs: +`target/t748-management-{root-check,full-suite,workdir-default,workdir-http}.log`. +Docs-only publication `6d8c49b7` changed no tested code. MR seq11 independently +confirmed those fixes and repeated the full validation, but requested changes for +cancelled trust-key revocation; it did not approve the complete snapshot. + +## Trust-key revocation must own post-commit subscription termination + +Seq11 major `T748-RUNTIME-REVOKE-CANCEL-PUBLICATION` reproduced the authenticated +public DELETE trust-key route waiting for an actual SQLite writer. Cancelling its +HTTP waiter did not cancel accepted SQL: revoke, binding-ID replacement and audit +committed, but waiter-owned expectation removal and broker unregister were lost. +The independent artifact used the normal public Server library's inner router, +not outer Workspace dispatch or live-stream/E2E. New signed requests still fail +the live binding fence; that does not shut down an already connected subscription. + +Fix `1a065db0` gives the whole key-revoke outcome one accepted finite Host owner: +intact CAS/audit SQL on the independent DB lane, local expectation removal, broker +unregister/Shutdown, and response observation. Owner/scope/input preflight, typed +trust conflicts and current-binding error responses remain unchanged. Response +reads use the already-owned `_on_host` detail helper, with no nested Host admission. +Admission/unknown-outcome errors retain the normal typed availability mapping; +there is no transaction split, new queue budget or automatic mutation retry. +The adjacent key-mutation caller audit confirms registration and metadata already +own publication and this is the sole production key-revoke caller. + +The permanent cancellation regression exercises the **real outer Workspace +dispatch**, owner browser cookie, generated DELETE route, whole store mutation and +broker. Projection producers stop before the write lock is acquired, so the +fixture-local busy observer identifies this actual revoke transaction, not startup +SQL. Timer/control progress before the watchdog releases SQLite. After waiter +cancellation, the real admitted RequestLease keeps deletion Pending. Releasing the +writer and draining actual Host/DB owners persists exactly one revoke audit, +changes binding ID, removes expectations and broker registration, and delivers +`Closed/ServerShutdown` to the downstream. The loopback endpoint holds a WebSocket +handshake; this proves broker termination, not an established upstream socket or +complete product E2E. Existing broker protocol tests cover socket replacement. + +A separate full-Host route regression observes 503 before mutation and unchanged +binding, audit and publication. Restoring only the exact old revoke method fails +both permanent tests: the cancelled job leaves broker registration behind, and +under overload the old handler commits revoke before its response helper returns +503. Fixed code passes both without retry or reduced parallelism. Evidence: +`target/t748-revoke-final-old-publication-red.log`, +`target/t748-revoke-fixed-boundaries.log` (18), `target/t748-revoke-policy.log` (3), +`target/t748-revoke-binding-policy.log` (16). +An initial counter probe outside dispatch was invalid because dispatch replaces it +with the real RequestLease; it was corrected to observe actual deletion drain. +A test-only overload channel type-inference compile rejection was corrected with +an explicit unit channel. Neither setup error is counted as semantic evidence. + +Exact code `1a065db0fc5d07e2dcc1b9dac2fcfc0607b9a23c` passed root `cargo check` and +the original default-parallel Server **971 passed / 0 failed / 1 existing ignored**, +11 binary tests; library duration **173.05 seconds**. Only three deliberate executor +panic diagnostics appeared, and no retained test/repro processes were observed. +Workdir default153/http-client156, fmt/diff and naming558/0/0 passed. All Cargo +checks/tests use the isolated candidate cache. Logs: +`target/t748-revoke-{root-check,full-suite,workdir-default,workdir-http,fmt,names}.log`. +Docs-only source `ebce7ab7` changed no tested code. MR seq13 independently confirmed +the revoke ownership fix and repeated the full checks, but requested changes for +Ticket cancellation, selector repair and post-restore projection's async callers. +The current uncommitted follow-up and the requested comprehensive caller audit +are tracked in [the caller inventory](2026-10-12-t748-caller-inventory.md). +Successful suites are not evidence that uninspected caller boundaries are safe. + +No latency benchmark, production incident measurement, target integration, +Ticket conclusion, or running-environment update is claimed. T743 has now +published its caller inventory/source; its owner will compose the T748 facade and +new callers, then obtain review of that exact combined source. T748 does not copy +those domain APIs or wait on the former unpublished branch. diff --git a/docs/report/2026-10-12-t748-t743-composition.md b/docs/report/2026-10-12-t748-t743-composition.md new file mode 100644 index 000000000..b5b94b67a --- /dev/null +++ b/docs/report/2026-10-12-t748-t743-composition.md @@ -0,0 +1,34 @@ +# T748 / T743 orchestrated composition + +## Exact inputs and ownership + +- Integrated standalone T743 target: `05ba6a43adafc2a74fac29295b906bcb30f83f11`. +- Independently approved T748 source: `e436b2ae58e940db416a5d6a9c2edb1368ec7600`. +- Shared original develop base: `56795fb92cb9def9bcd701bd51d92e99526f8eff`. + +T743's corrective revert deliberately removed the prematurely imported T748 tree while preserving published history. Ordinary merge therefore reports historical modify/delete conflicts; explicit composition against the original develop base restores the approved T748 platform while retaining standalone T743's public APIs/UI/Tools/Prompt. The resulting commit has both real input parents: no history rewrite, substituted source candidate, or change to T743's concluded requirements. The Orchestrator owns this composition and target integration; T743 is not reopened or gated on another lane. + +## New caller execution boundaries + +- `ticket_assignments_page` and `ticket_assignment_diagnostics`: owned clones and reference/scope lookup plus the whole existing synchronous query helper run on the shared finite DB executor. +- `ticket_assignment_answer`: mixed Runtime source discovery, intact source/CAS/replay/audit/receipt/commit helper, and post-commit orchestrator discovery/delivery run under one accepted finite Host owner. Runtime/network waits never occupy the sole DB worker. The transaction is not divided by awaits, nested Host admission is not introduced, and request cancellation does not cancel/replay accepted work or lose commit-before-publication responsibility. +- Browser forwarders remain delegates to those canonical methods; no second domain authority or DTO is introduced. + +## Orchestrator validation + +Commands used the repository's original default parallelism and locked dependencies: + +- Root `cargo check --locked`: pass. +- Assignment tracking library suite: 28 pass, including public answer source/replay/CAS/reassignment and page/cursor authorization semantics. +- DB execution regressions: 36 pass (the 33 approved upstream tests plus 3 new public caller tests). +- Complete `yoi-workspace-server --all-targets`: library 1007 pass / 0 fail / 1 pre-existing ignored; binary 11 pass. Library completed in 189.81 seconds. +- `workdir --all-targets`: 153 pass; with `http-client`: 156 pass. +- Root format check and staged/unstaged diff checks: pass. + +New public-caller tests use the real generated Browser handlers on a current-thread Tokio runtime, explicit isolated finite executor budgets, and a deterministic actual owner/connection hold. After job admission, independent control and timer progress, cancellation leaves the queued slot occupied, overload is typed HTTP 503, drain waits for actual completion, and subsequent normal lookup has reusable capacity. They do not claim successful answer commit or Runtime E2E delivery; the existing domain and real SQLite regressions cover those distinct boundaries. + +Causal red/green: replacing only the three new adapters with their standalone T743 synchronous bodies made all 3 new tests fail after the bounded 5-second watchdog released the held connection; restoring the finite adapters made all 3 pass. Both logs remain in the integration Workdir's `target/t748-integration-public-boundary{,-red}.log`. Complete suite, DB, and Workdir logs use the same `target/t748-integration-*` prefix. An initial Workdir command used the nonexistent package name `yoi-workdir` and failed selection before running tests; the correct Cargo package is `workdir`, as subsequently validated. No failed test was retried to pick a green result. + +## Review and integration status at publication + +This is an Orchestrator-authored candidate for exact-source independent review, not a claim that prior T748 approval covers this new composition. The stable `work/T-748-orchestrator-composition` ref publishes the merge candidate without updating develop. The existing assigned T748 Coder receives it for a normal merge into the same immutable MR source selector and owns the trusted Reviewer/fix loop. Only the Orchestrator will update develop after fresh exact-source readiness. No replacement MR, new Reviewer Runtime Worker, target integration, live deployment, Ticket conclusion, or resource cleanup is claimed here. diff --git a/docs/report/2026-10-12-worker-observation-followup-gap.md b/docs/report/2026-10-12-worker-observation-followup-gap.md new file mode 100644 index 000000000..fe5ef448a --- /dev/null +++ b/docs/report/2026-10-12-worker-observation-followup-gap.md @@ -0,0 +1,49 @@ +# Internal SubWorker follow-up observation gap + +## Observed barrier + +During T748's read-only caller inventory, the parent could read the first Runtime +inventory assistant result by stable entry ref +`E01a12e76-3893-70a0-bb73-28ee0c52f87b`. It then sent a follow-up to the same +Idle direct child, `t748-runtime-caller-inventory`, to close four remaining +execution/ownership boundaries. + +After a turn-ended/Idle notification, `WorkerList` confirmed Idle, but bounded +`SearchSessionEntries` for assistant entries after follow-up user ref +`E01a12e76-d696-73d3-80d7-b8cde9935b37` returned no entries. Tool observations +were visible. The overview showed only the initial and first follow-up user +anchors, not a final result. A single additional Submit asking for the existing +findings (not re-investigation) returned success. After its turn-ended/Idle +notification, the same bounded assistant query still returned no entries, and the +overview still ended at the first follow-up user anchor. + +This is an observation-surface symptom, **not proof** that an assistant result +was generated, lost, or that the underlying investigation completed. No raw +session storage or hidden worker state was read to bypass the projection. + +The same symptom also occurred for `t748-domain-caller-inventory`: its first +result `E01a12e73-7ae4-7f02-9a7e-203140971635` was readable, but after the +follow-up turn-ended/Idle notification the overview ended at the follow-up's +starting commentary `E01a12e75-9c19-7bd0-9284-299116a05761` and intervening tool +records. No final follow-up result was visible. The parent released that +read-only delegation without another Submit and took over the remaining generated +adapter/forwarder and launch lower-edge checks. Neither `WorkerStop` tool outcome +was treated as evidence that the investigation completed. + +## Handling + +The parent did not mark the delegated work or Ticket complete, publish source, +or replace independent review with the notification. It stopped only the two +parent-owned read-only children to release delegation and took over the open +Runtime and domain boundaries. No live product process, parent Worker, target +branch, or attachment was stopped or cleaned. + +## Improvement proposal + +Expose enough committed turn metadata through the authorized observation surface +to distinguish a finalized assistant result, a turn ending without a result, and +an observation capture not yet including an accepted Submit. An explicit +completion marker tied to the visible Submit entry would prevent an Idle +notification from being confused with inspectable evidence. Preserve append-only +session entries and the current authorization projection; do not expose raw +prompts/reasoning or require polling/retry loops as a workaround. diff --git a/docs/report/T-738-compaction-summary-validation.md b/docs/report/T-738-compaction-summary-validation.md new file mode 100644 index 000000000..aa9880916 --- /dev/null +++ b/docs/report/T-738-compaction-summary-validation.md @@ -0,0 +1,69 @@ +# Compaction summary input validation (T-738) + +## Baseline and scope + +Implementation starts at `6a12b6cc346402b7a10208685038967171b09cb3`. +Compared with the Ticket's inspected `3263d501`, the compaction input, capture and +prompt contracts are unchanged; intervening Worker edits concern state-contract +naming, resource/manage-worker projections, and shared state. The defects reported +against `3263d501` therefore still apply to this checkout. + +The change fixes mandatory previous-summary transport and the continuity policy. +It does not add Journal integration (T-745), a separate directive ledger, exploration +of historical Segments, or a comprehensive internal-compactor audit log. See +[the current design](../design/compaction.md) for the input and retention contracts. + +## Acceptance evidence + +| Ticket condition | Deterministic evidence | +| --- | --- | +| 1: full initial input, long summary and recent activity | `full_initial_summary_survives_small_overview_budgets_and_both_retention_extremes` observes the actual first compactor `LlmClient::Request`; full body equality, one occurrence, and no clipped duplicate in overview. Summary exceeds 4,000 characters with directives/end sentinel after the long prefix, plus 80 recent messages. | +| 2: small target/warning/fallback, context accounting, hard failure | Same matrix uses target=1 and warning/deadline settings 0/1. `inherited_summary_counts_against_hard_input_limit_and_failure_does_not_activate` compares the same overview with/without a mandatory summary above the cap: no compactor request, no replacement Segment, unchanged active metadata/history on rejection. `budget_warning_is_durable_and_keeps_full_summary_in_first_model_request` verifies the warning survives in the next request. Interceptor tests cover request-shape estimation, measured occupancy without double counting, and a warning that cannot fit. | +| 3: absent summary, restore, retained boundary, other derived context | Matrix covers no retained tail and retaining all ordinary items. `pasted_markers_and_other_derived_context_remain_ordinary_overview_material` covers absent summary, auto-read, TaskStore, ordinary system, user paste and later derived markers. `restored_legacy_summary_requires_compacted_lineage_first_position_origin_role_and_marker` covers the exact adopted old Host seed boundary and its counterexamples. Invalid or duplicate typed Host summaries fail closed. | +| 4: privacy and lightweight overview | Existing tool-argument/tool-result/reasoning omission tests remain. `host_summary_remains_private_to_compaction_not_observation_or_memory_evidence` verifies overview, search, read, and evidence exclude Host summary, other derived/system and reasoning bodies. The persisted retained-history paging fixture includes the new Host summary origin and still excludes derived content. Public provenance maps to the existing excluded `DerivedSummary` class; no public observation/Memory capability is added. | +| 5: prompt and shortening policy | Resource policy test covers initial summary, segment user messages, prohibitions, completion conditions, changes/retractions, uncertainty, TaskStore distinction and retained tail. Shrink-policy and budget-warning tests cover the corresponding priorities. Output-hard-max integration test exercises the actual rewrite path and explicit failure if output remains oversized. | +| 6: at least three chained compacts | `three_chained_compactions_transport_each_complete_predecessor_without_retaining_old_summaries` starts with a long summary carrying a late original-goal sentinel; each of three compacts receives a full predecessor and a new user directive containing temporary recovery/SubWorker collection instructions. It checks each actual initial request, one current typed summary, predecessor lineage and persisted result. | +| 7: tests/check/format and current design | Commands below pass; design input, privacy, retention, limit and assessment descriptions are updated. | + +## Executed validation + +During implementation, the smallest filters were run first: + +- `cargo test -p worker --lib build_summary_prompt_tests::overview` +- `cargo test -p worker --lib compact_worker_` +- `cargo test -p worker --lib session_history::` +- `cargo test -p worker --lib session_capture::` +- `cargo test -p worker --lib build_summary_prompt_tests::compact_prompt_and_shrink` +- `cargo test -p worker --lib malformed_or_duplicate_host` +- `cargo test -p worker --test compact_events_test inherited_summary_counts_against_hard_input_limit_and_failure_does_not_activate` +- `cargo test -p worker --test compact_events_test` (25 tests) +- `cargo test -p session-store --lib retained_history_pages_twelve_turns` + +Final default-configuration validation: + +- `cargo check` at workspace root (normal compile closure, including TUI/Server) +- `cargo test -p worker` (library, integration targets, doctests) +- `cargo test -p session-store` (library, integration targets, doctests) +- `cargo fmt --all -- --check` +- `git diff --check HEAD` +- `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts` (0 unapproved, 0 stale exceptions) + +No added contract depends on an optional Worker feature. Full-workspace tests, +`--all-targets`, live provider calls, E2E, Nix and Docker builds were not run. + +## Unverified semantic and operational behavior + +Predetermined mock `write_summary` results prove transport, input policy and +replacement behavior, **not** a real model's semantic retention or comprehension. +Live semantic evaluation remains pending: use a real model across at least three +compacts, score original scope/prohibitions/completion/exception-reporting duties +and explicit subsequent changes separately from transport success, and verify that +temporary recovery procedures do not displace the continuing request. Do not credit +an echo fixture as semantic evidence. + +The restore fixture exercises public `rewind_to` and the shared typed restoration +path over persisted annotated records. It is not a cold Runtime restart test. +Manual and automatic compaction use the same `compact_impl`; existing automatic +resume tests pass, but no separate live automatic-compaction semantic evaluation +was run. Initial context accounting is an estimate, not exact provider tokenization; +provider failure must still fail the run without activation. diff --git a/docs/report/T-740-develop-composition.md b/docs/report/T-740-develop-composition.md new file mode 100644 index 000000000..cb71fa64e --- /dev/null +++ b/docs/report/T-740-develop-composition.md @@ -0,0 +1,49 @@ +# T-740: composition with develop 71ab4c06 + +## Source reconciliation + +The existing `work/T-740-workspace-ticket-tags` source at `e9397fa0` was clean. The requested develop commit `71ab4c06b9ae24ca4daa88516d1e436c5a31c779` was fetched and merged normally into this same branch (no replacement MR or integration branch). Generated conflicts in OpenAPI and companion/repository/runtime/ticket/worker-launch types were resolved by regeneration, not by selecting one side's contract. Workdir types were regenerated as well because the shared contract digest changed. + +The merged Rust contract retains both classification operations/projections and the develop Runtime binding/per-request authentication model. Workspace schema remains 90; Ticket classification schema remains 8. The Runtime migration archives historical verification metadata, does not make it a grant, and does not rebuild Ticket/tag tables. Shared server/store/schema-equivalence/purge inventory changes were inspected against develop: both Runtime archives and all three classification tables are included. + +Official generators rerun: + +- canonical `export_openapi`; +- repository/runtime/ticket/workdir/worker-launch/companion OpenAPI schema-closure generators; +- feature-gated memory/skill/auth/repository-access/Drive/legacy Server type catalogs. Their checked-in representation conventions are preserved (auth uses the raw normalized generator output, not Deno reformatting). + +## Composition defect fixed + +New signed-Runtime router tests found that classification handlers discarded the verified Runtime Worker identity when optional transport source-hint headers were absent. This produced empty attribution in durable tag receipts and allowed another authenticated Worker to replay the same operation key. + +Classification now takes the verified Runtime source from `ServerRequestContext`, retains the existing source-hint verification path, rejects mismatched identities, and carries the actual Worker identity into event/audit and exact-replay input. Runtime-only claims without a Worker identity cannot impersonate a human classification actor. Request proof tokens are not operation identity; exact replay with a new valid signature from the same Worker is accepted, while another Worker conflicts. Other Ticket operation contracts are not respecified by this correction. + +## Executed validation + +All fixtures are hermetic, temporary, in-process boundaries following `docs/development/rust-testing-strategy.md`. + +- `cargo test -p yoi-workspace-server --lib ticket_tags_tests`: 6 passed (original 3 plus signed Runtime lifecycle/replay, identity-bound replay, and rejection matrix). +- Rejection coverage: unsigned hints, forged signing key, unknown Worker, other Workspace claim, body tampering, stale pinned key, and administratively revoked binding, for both catalog mutation and Ticket membership endpoints; no rejected-operation writes or execution effects. +- `current_workspace90_without_classification_upgrades_ticket7_without_rewriting_ticket_history`: passed. A current Workspace-90/pre-classification Ticket-7 fixture upgrades to Ticket-8 without rewriting an existing closed Ticket, labels, decision/history, or Workspace auth schema; arbitrary `ready` classification preserves the decision. +- `workspace89_to90_runtime_rebuild_preserves_tag_links_tombstones_receipts_and_audit`: passed. Real retained migration plus tagged data preserves exact current links, tombstones/recreated identities, receipt/context/audit rows and thread snapshots; replay and FK/deleted-reference rejection remain intact. +- `fresh_and_upgraded_state_contract_schemas_enforce_the_same_constraints`: passed, including classification tables and Runtime archives. +- `cargo test -p ticket --lib tags`: 14 passed. +- `cargo test -p worker --lib feature::builtin::ticket::tags`: 7 passed, covering normal tools/native WIP, permission, route binding and durable mutation/replay contracts after composition. +- `cargo test -p worker-runtime --lib workspace_issuer`: 12 passed. +- Full `cargo test -p ticket -p server-api -p client -p worker -p yoi-workspace-server`: passed including integration/doc targets. Unit suites: client 57, server-api 92, ticket 120, worker 975, Server 830 passed/1 existing ignored. The compaction integration target's 25 tests also passed. +- `cargo test -p server-api --features typescript`: 101 passed; all feature-catalog artifact tests passed. +- Root `cargo check`: passed, including normal TUI/Server closure. +- Canonical OpenAPI and all six OpenAPI-based generator `--check` runs: passed. +- `cargo fmt --all -- --check`, `git diff --check HEAD`: passed. + +Initial signed-source tests failed on attribution/replay and passed after the fix. An attempted Deno format of auth catalog output failed its exact generated-artifact test; regenerating it in the documented raw form restored the artifact and all 101 feature tests passed. + +## Naming audit limitation inherited from target + +The required state-contract-name command was executed. It reports **30 unapproved lines and 9 stale exception hashes** in develop's frozen SSH migration/fixtures, migration-entrypoint fixture and timestamp-mismatch report. Every affected file and the exception inventory are byte-for-byte unchanged from `71ab4c06` (`git diff --exit-code 71ab4c06 -- <affected paths>` passed). The T-740 integration additions introduce no such finding. The inventory was not expanded, hashes were not silently refreshed, and no upstream historical contracts were renamed merely to make the checker green. This audit is **not reported as passing** and remains an Orchestrator/user follow-up outside the classification correction. + +## Limits / handoff + +T-739 is not integrated: its future case-state/close/migration composition is **not verified** by these tests. T-743 final Web UI, live production DB migration and full end-to-end UI/Runtime execution are not claimed. The existing ignored Server Drive Web-adapter roundtrip was not executed. The current-DB upgrade test covers a seeded schema-90 fixture, not live user data. + +Only the existing source branch is to be published non-force. Source movement requires fresh trusted review on the same explicit MR `01a126a0-b4b1-7a13-ba61-5b2c06a3bca4` and current Ticket digest. Target publication, MR integration and Ticket completion/close are not performed here. diff --git a/docs/report/T-740-subworker-cwd-scope.md b/docs/report/T-740-subworker-cwd-scope.md new file mode 100644 index 000000000..2fbe12744 --- /dev/null +++ b/docs/report/T-740-subworker-cwd-scope.md @@ -0,0 +1,9 @@ +# T-740: delegated cwd and attachment coordinates + +During T-740, two implementation children were spawned with a read rule for `.` and narrow write rules, plus `cwd: crates/ticket` or `cwd: crates/server-api`. Their committed sessions reported only the narrow crate attachment accessible; `docs/development/rust-testing-strategy.md` and, for the API child, the delegated client/Worker files were inaccessible. The API child correctly stopped rather than trying to bypass the denial. Its partial API changes were inspected and retained. + +The parent had already read the test-design policy and reviewed the delegated tests against it. Remaining client/Worker work was assigned to a new child with `cwd: .`; that child could read the policy and implement its delegated targets. Parent integration and tests verified the actual checkout, not only child summaries. + +This observation does not establish the provider's internal cause. The public `SubWorkerSpawn` description says cwd selects tool working directory, not Workdir/Profile/memory/Ticket roots or additional authority. Narrow cwd plus wider read/delegation rules should have predictable attachment coordinates, or the tool should report that only a restricted attachment can be provided before work starts. An early capability/attachment summary would avoid partial implementations and make mandatory policy-document access verifiable. + +No authority workaround, process stop of the running host, or change to the control-plane tooling is included in T-740. diff --git a/docs/report/T-740-validation.md b/docs/report/T-740-validation.md new file mode 100644 index 000000000..cefcddf3d --- /dev/null +++ b/docs/report/T-740-validation.md @@ -0,0 +1,50 @@ +# T-740 implementation and validation + +## Result + +Classification foundation is implemented independently of case-state migration and the final Web UI. Contract/design and T-739/T-743 integration notes: `docs/design/workspace-ticket-tags.md`. + +Changed boundaries: + +- `ticket`: stable Workspace tag identity, normalized display-name uniqueness, tag catalog mutation/list, transactional multi-membership replacement, observation comparisons, identity-bound exact receipts, historical snapshots, schema v8 migration/verification, and projections. +- `server-api` / `client`: typed scoped routes and client methods, list/detail/query tag fields, AND/OR/NOT ID filters, OpenAPI and browser type generation. +- `workspace-server`: generated route registration, authenticated source attribution, SQL filtering before bounded candidate scans, authoritative projection recheck, suppression of execution/routing notifications, retained pre-classification schema verification and fixture correction, Workspace deletion purge inventory. +- `worker`: normal classification tools, native collection/item WIP operations, query filters, current identities/observation keys, and historical before/after snapshots in model-visible tag-change events. + +## Executed checks + +Tests were designed against `docs/development/rust-testing-strategy.md`. Narrow development checks included: + +- `cargo test -p ticket --lib tags`: 14 tag authority tests passed, including name policy, duplicate/foreign/deleted references, stale observation, exact/incorrect replay, changed actor, assignment-context changes, multi-attach/remove, rollback, concurrent delete/attach and replay, no-op audit semantics, bounded lists/sets, and control-word tags across all existing states. +- `cargo test -p server-api --lib ticket_tag`: 4 passed; with `--features typescript`, 5 passed. +- `cargo test -p worker --lib feature::builtin::ticket::tags`: 7 passed, covering model-visible schemas, permission/authority errors, scoped HTTP operations and WIP binding. +- `cargo test -p worker --lib ticket_tag_history_projection`: passed. +- `cargo test -p yoi-workspace-server --lib ticket_tags_tests`: 3 passed, covering real router mutation/projection/filter, authentication/forged source/other Workspace rejection, actor-bound receipts, no state/content/assignment/grant/execution projection changes, and query pagination/filter-bound cursors. +- `cargo test -p yoi-workspace-server --lib fresh_and_upgraded_state_contract_schemas`: passed, including tag tables/constraints. +- `cargo test -p yoi-workspace-server --lib schema_v65_migrates`: passed after distinguishing retained intermediate schema from current classification schema. +- `cargo test -p yoi-workspace-server --lib explicit_purge_inventory`: passed. + +Full changed-crate command passed: + +```sh +cargo test -p ticket -p server-api -p client -p worker -p yoi-workspace-server --quiet +``` + +Unit suites at that point: client 57, server-api 92, ticket 120, worker 968, workspace-server 814 passed / 1 ignored. All included integration and doc-test targets also passed. Worker was subsequently rerun after adding the historical tag projection test (969 unit tests); its included integration/doc tests passed. + +The one ignored Server test is the existing repositoryless Drive real-Web-adapter roundtrip, unrelated to classification. It was not executed. + +- Root `cargo check`: passed, including normal TUI/Server compile closure. +- `cargo test -p server-api --features typescript`: 101 passed; doc tests passed. Existing ts-rs warnings about unsupported serde attributes remain. +- Canonical `export_openapi -- --check`: passed. +- `generate_repository_openapi_types`, `generate_runtime_api_types`, `generate_ticket_api_types`, `generate_workdir_api_types`, `generate_worker_launch_api_types`, `generate_companion_api_types` with `-- --check`: all passed. Other feature-gated type catalogs are protected by the full server-api TypeScript tests. +- `cargo fmt --all -- --check`, `git diff --check HEAD`: passed. +- `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts`: passed, 0 unapproved / 0 stale exceptions; exception inventory unchanged. + +Initial development/full-suite failures concerned in-flight trait implementations, stale generated artifacts, status-code expectations, and a retained schema fixture/intermediate verifier. They were corrected and the commands above rerun; the final changed-crate run had no failures. + +## Not claimed / follow-up + +No final Web tag UI, full browser/TUI/runtime E2E, real-model judgment test, workspace-wide test run, Nix/Docker build, or live production-data migration was performed. T-739 branch integration is not tested here: schema composition and contract regeneration must preserve the catalog, memberships, tombstones, receipts and tag audit/thread events. This branch is independently buildable on the existing Ticket-state model. + +MR publication and trusted independent review are separate handoff evidence. This implementation does not merge the MR or complete/close T-740. diff --git a/docs/report/T-743-T748-callers.md b/docs/report/T-743-T748-callers.md new file mode 100644 index 000000000..8f98f2553 --- /dev/null +++ b/docs/report/T-743-T748-callers.md @@ -0,0 +1,88 @@ +> Historical evidence: the user withdrew composition/wait instructions below. +> Current standalone correction and fresh validation/review evidence: +> `T-743-standalone-correction.md`. The original body is retained, not a T-748 return gate. + +# T-743 → T-748: published domain-helper / caller handoff + +## Source identity and composition + +This is the retained pre-composition caller inventory published with T-743 +`9b0d42fdbc2920736c3418bb894f26140e962bbc`. Its descriptions of direct callers +below identify the work to move, not the current execution placement. The later +normal merge, actual DB/Host placement, ten new regressions, full-suite evidence +and unresolved upstream review findings are in `T-743-T748-composition.md`. + +This inventory belongs to the T-743 commit carrying this file on `work/T-743-ticket-public-surfaces` (T-742 base `56795fb92cb9def9bcd701bd51d92e99526f8eff`). Its exact published SHA and MR ID are recorded in the Ticket publication handoff. It is not a copy of the unpublished T-748 adapter and is not evidence that async DB execution has been integrated or reviewed. + +Normal-merge the validated public T-748 source into this existing work branch. Keep the MR's source/target selectors. Recheck the concrete new call graph below against the merged implementation; no placeholder executor, second connection authority, transaction split, fresh integration branch or force push is needed. + +## Three added synchronous Store signatures + +Declared in `crates/workspace-server/src/store.rs` on `ControlPlaneStore`, implemented by `SqliteWorkspaceStore`: + +```rust +fn page_ticket_assignments( + &self, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, +) -> Result<ticket::TicketAssignmentsPage>; + +fn page_ticket_assignment_diagnostics( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_input_event_id: Option<&str>, + after_start_operation_id: Option<&str>, + limit: usize, +) -> Result<server_api::TicketAssignmentDiagnosticsPage>; + +fn answer_ticket_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + input: &ticket::TicketAssignmentAnswer, + source: &TicketOrchestratorAssignmentSourceContext, + actor: &str, +) -> Result<ticket::TicketAssignmentAnswerReceipt>; +``` + +Implementations forward whole calls respectively to `assignment_tracking::assignments_page`, `assignment_delivery::diagnostics_page` and `assignment_tracking::answer`. + +The answer helper owns one IMMEDIATE transaction: `require_assignment_source` (including replay), current actionable delegation admission for a new operation, `ticket::sqlite_answer_ticket_assignment_in_transaction` (full input/source fingerprint, retained receipt replay, observed assignment/event, open-case gate, body event, audit attributes and immutable operation result), then commit. Keep this complete helper inside one accepted DB job. Do not validate/CAS on one side of an await and append/receipt on the other. The two read helpers use their existing connection/snapshot boundaries and bounded cursors; the diagnostics input and start cursors are independent. + +## Added async HTTP callers and surrounding synchronous work + +In `crates/workspace-server/src/server.rs`, `WorkspaceContractService`: + +| Canonical caller | Browser forwarding caller | Whole Store operation | +| --- | --- | --- | +| `ticket_assignments_page` | `ticket_assignments_page_browser` | `page_ticket_assignments` | +| `ticket_assignment_diagnostics` | `ticket_assignment_diagnostics_browser` | `page_ticket_assignment_diagnostics` | +| `ticket_assignment_answer` | `ticket_assignment_answer_browser` | `answer_ticket_assignment` | + +Canonical paths are `/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments`, `/assignment-diagnostics` and `/assignment-answer`; Browser equivalents use `/api/w/{workspace_id}/tickets/{ticket_id}/...`. Browser methods forward to canonical methods, so they must not independently repeat a mutation or transaction. + +All three canonical methods currently call `resolve_workspace_ticket_reference` before their Store method. This uses Ticket authority/backend DB reads, so moving only the final Store method is not enough. The answer path also calls `authenticated_assignment_source`; its Runtime branch checks the registered current Workspace Orchestrator through `find_workspace_orchestrator`, while its Account branch derives actor/source exclusively from authenticated request context. Preserve source authentication and the in-transaction source fence; do not turn request target principal into source authority. Own/cloned workspace/Ticket/query/body/context/actor values must cross the common execution boundary without borrowing request frames. + +After the answer returns committed, the current handler calls `find_workspace_orchestrator` and `dispatch_orchestrator_assignment_attention`. Account for the former's DB-backed projection lookup and the latter's Runtime/network dispatch in T-748's existing stage/lane design. Runtime/network waits must not occupy the sole DB worker. Keep commit-before-publication and durable scan/recovery semantics even if the awaiting HTTP request is cancelled or its response is lost. Do not automatically resubmit an accepted mutation with a new operation key. + +## Query authority extension and all entry paths + +`TicketQueryRequest` adds typed `assigned_principal`, `handling_status` and `closure_reason`. Existing `TicketAuthority::query_tickets(query: TicketQueryRequest) -> Result<TicketQueryResponse>` in `authority.rs` applies current role/principal/handling and current closed-case reason constraints in its SQL candidate query **before** limit/pagination. Query text separately searches retained closure judgments, including after correction/reopen. + +The HTTP call graph remains `WorkspaceContractService::ticket_query` → `scoped_query_tickets` → `api.authority.query_tickets(query)`. Canonical and Browser query routing, including the pre-existing browser GET query adapter, converge on this authority. T-748 must cover the whole authority read/enrichment call, not just the added SQL fragment or only one UI route. + +Normal Tool/WIP read/assign/release/answer calls forward typed `WorkspaceServerOperation` values from `crates/worker/src/worker.rs`, through `crates/worker-runtime/src/worker_source.rs`, to the same canonical HTTP contracts. Added variants are `TicketAssignmentSet`, `TicketAssignmentClear`, `TicketAssignmentsPage`, `TicketAssignmentDiagnostics`, and `TicketAssignmentAnswer`; `QueryTicket` uses the existing typed query transport with the added filters. The set/clear variants expose existing assignment Store authority rather than creating additional new Store helpers. Their existing T-748 execution-boundary changes must be retained when resolving merge conflicts. + +No new Tool-specific DB adapter is needed. Frontend helpers in `ticket-operations.ts`, TicketHandling/Tags and list/Orchestrator loaders are consumers, never replacement authorities. + +## Composition validation + +Existing T-743 proof: full selected Rust crate suites, generated contract checks, Web/component/browser tests and real-Worker notification/processing synchronization. See `T-743-public-surface-validation.md` for commands and explicit limits. + +After T-748 composition, add/run the narrow boundary checks for all three new HTTP operations and query paths under controlled DB lock/admission, queue/job bounds, overload response, stale/source refusal, rollback, accepted-job cancellation and exact replay. Retain cookie Origin and signed Runtime-binding denial tests, assignment source fencing, one-transaction receipt/event tests and canonical/Browser route equivalence. Root check and relevant full changed-crate tests must cover the combined tree. Then publish only the same source branch normally, verify provider SHA equals local HEAD, and request fresh independent review of that exact combined source. + +Current publication is neither final independent approval nor target integration, Ticket/MR completion, live update or cleanup. diff --git a/docs/report/T-743-T748-composition.md b/docs/report/T-743-T748-composition.md new file mode 100644 index 000000000..056ab9db7 --- /dev/null +++ b/docs/report/T-743-T748-composition.md @@ -0,0 +1,134 @@ +> Historical evidence: the user withdrew composition/wait instructions below. +> Current standalone correction and fresh validation/review evidence: +> `T-743-standalone-correction.md`. The original body is retained, not a T-748 return gate. + +# T-743 / T-748 composition: execution-boundary evidence + +## Source and status + +T-743 continues on `work/T-743-ticket-public-surfaces`, MR +`01a12d19-b61b-79a2-baca-89241fd3c3d3` → `develop`. The published T-748 +source `66bb9d0b825d72e20923003b273b787007f3aed8` was normally fetched and +merged into that same source branch, without conflicts, replacement selectors, +force push, or a copied execution facade. The composition commit carrying this +report also connects the added callers below. Its exact published hash is in the +Ticket handoff and provider-resolved MR source. + +**This is a validated intermediate composition, not final independent approval.** +T-748 MR `01a12d3f-e90c-7b02-9ed9-cf3bd2e822a1` seq3 requests changes on this +upstream source. Its two major findings are not fixed by the new assignment +caller work: `T748-DB-NETWORK-LANE` (Subjektiv mixed Runtime/DB placement) and +`T748-ASYNC-PROTOCOL-SIGNING` (cold protocol observation source signing). +That authoritative verdict was checked again after the combined full suite; +no newer published upstream source or verdict was present. The T-748 owner was +informed through its Ticket of the composition and requested to provide the +normally advanced validated fix source. Do not interpret a green suite as proof +that those untested boundaries are safe. Normal-merge that fix into this same +T-743 source, revalidate, and independently review the exact combined source. + +## Caller placement + +- Canonical and Browser assignment pages and sanitized diagnostics share one + implementation. Workspace scope, Ticket reference resolution and each whole + synchronous Store read run on the shared bounded DB owner. None is left as a + synchronous pre-call on a Tokio request thread. +- Typed Ticket query, including principal/handling/current-closure filtering + before pagination, retains T-748's whole authority/read/enrichment DB job. + The regression holds the **authority's actual candidate connection**, not the + separate API Store connection used by only some enrichment paths. This also + proves the empty-result path and wrong-principal filters offload correctly. +- Account assignment answers derive attribution from Backend-authenticated + context inside the owned DB closure. Runtime answers preserve the live current + Orchestrator discovery check on the finite Host lane, because Runtime worker + lookup can perform remote HTTP. The DB closure derives Runtime attribution + without I/O and the unchanged answer helper validates current singleton and + live binding **again under its IMMEDIATE write transaction**, including replay. + Target principal and request-body prose never supply author authority. +- The same answer helper keeps source authority, actionable-assignment gate, + assignment/event CAS, full operation fingerprint/replay, body, audit, receipt + and commit together. There is no await within that transaction and no automatic + mutation retry. +- The accepted SQL job admits the existing finite Host attention continuation + **after commit and before returning its result**. It inherits the request + lifecycle source, owns lookup/Runtime dispatch independently of the waiter and + does not wait on Runtime/network on the sole DB worker. A live HTTP waiter + observes best-effort notification completion; losing the waiter does not + cancel accepted SQL or accepted publication. Rejected Host publication retains + the existing saved-input scan/recovery and diagnostic behavior, not rollback + or a synthetic successful delivery. +- Normal Tools/WIP still converge on the canonical HTTP contracts. No second + ledger, query adapter, source authority, read pool or delivery implementation + was added. Existing T-748 set/clear/start/delivery boundaries remain intact. + +## New regression proof + +`crates/workspace-server/src/server_assignment_db_execution_tests.rs` contains +10 tests on fixture-owned bounded DB/Host owners, temporary databases and explicit +observable gates. Parent inspected the child's committed tool output, the actual +new test code and then the combined default-parallel suite, rather than treating +an end notification as proof. The child's delegated file scope was released. + +The regressions prove: + +1. Assignment/diagnostic read and Ticket reference resolution wait off the + current-thread executor on their actual Store connection mutex. +2. Typed principal/handling/closure query matrices, including empty results, + hold the candidate connection and filter before the page limit. +3. A real second SQLite connection's IMMEDIATE writer reaches the answer's owned + busy observer while the same Tokio runtime's timer and independent control + progress; one transaction preserves the answer/event/audit/receipt. +4. Saturated DB admission returns HTTP 503 and does not accept an answer. +5. Cancelling an accepted answer waiter retains its actual job slot/lifecycle + through SQL and Host publication; body/audit/receipt persist, dispatch is once, + and actual cleanup releases all observed leases. +6. Successful HTTP answer waits for its admitted publication without occupying + the DB worker; an independent DB callback can progress during publication. +7. Account and Browser receipt replay, stale CAS, operation mismatch and bad + cursor/error mapping retain their domain meanings. +8. Real signed non-Orchestrator source and signed proof replay are denied with + no answer/receipt; signed current Orchestrator canonical/Browser replay shares + one source-bound receipt, and another source cannot replay it. +9. Saturated **2-worker/16-queued Host** live-discovery rejects canonical/Browser + Runtime answers before SQL admission, without receipt/audit/publication or + retained lifecycle, while independent DB callbacks progress. This last test + injects authenticated context at generated handlers to isolate discovery + admission; the preceding tests exercise actual signed-proof middleware. + +Read/query contention intentionally uses the actual connection mutex; answer +contention uses a separate real SQLite writer. Neither is described as a full +remote Runtime latency benchmark. No fixed completion sleeps, mutation retry, +parallelism reduction, process-global fixture budgets or new ignored tests were +introduced. Fixtures drain accepted Host/cleanup jobs, shut down projection +producers, then drain DB jobs before ending their original Tokio driver. Owned +busy-handler userdata and SQLite locks unregister/release through RAII. + +## Validation on this composition + +- Root `cargo check`: passed, normal default-member compile closure. +- `cargo test -p yoi-workspace-server --lib assignment_db_execution_tests`: + **10 passed, 0 failed**, 2.63s (child committed output). Parent reran the first + nine after separating Runtime discovery and included all ten in the full suite. +- `cargo test -p yoi-workspace-server`: original default parallelism, + **985 library passed, 0 failed, 1 pre-existing ignored**, 176.60s; + **11 binary passed**, doc tests passed. Log: + `target/t743-combined-server-suite.log`. +- `cargo test -p workdir`: **153 passed** across library/integration targets; + `cargo test -p workdir --features http-client`: **156 passed**, doc tests passed. + Logs: `target/t743-combined-workdir.log`, `target/t743-combined-workdir-http.log`. +- Combined Web check: **0 errors, 0 warnings**; static production build passed + with the existing chunk-size advisory. Web tests **613 passed**, components + **318 passed**, Chromium Ticket/principal browser tests **7 passed**. + Logs: `target/t743-combined-{web-check,web-build,web-tests,components,browser}.log`. + The last change is Server execution placement/test code, not a UI change. +- All six server-api TypeScript generator `--check` commands passed: ticket, + repository OpenAPI, runtime, workdir, Worker launch and companion. +- `cargo fmt --all -- --check`, `git diff --check HEAD` and + `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts` + passed; naming: **558 matching lines, 0 unapproved, 0 stale exceptions**. + +Earlier T-743 public-surface, five-crate, Prompt/Tool, visual and real-Worker +fixture evidence is retained in `T-743-public-surface-validation.md`. Scripted +model/loopback browser fixtures do not attest real model judgment or all T-744 +live paths. Target integration, Ticket/MR conclusion, live update and retained +Worker/Workdir cleanup were not performed. Fresh independent combined-source +review remains pending the upstream fixes, not silently waived. diff --git a/docs/report/T-743-assignment-tools-handoff.md b/docs/report/T-743-assignment-tools-handoff.md new file mode 100644 index 000000000..95cf75162 --- /dev/null +++ b/docs/report/T-743-assignment-tools-handoff.md @@ -0,0 +1,51 @@ +# T-743 additional assignment Tools / WIP handoff + +This is an additive follow-up to `T-743-prompt-surfaces.md`; that prior report/history is not rewritten. Additional scope follows T-743 thread sequence 6 and the parent request. Read current typed T-743 and inspected the backend child's actual Server DTOs/routes, not just the proposed names. + +## Implemented public surface + +| Normal Tool | Native Ticket item operation | Contract | +| --- | --- | --- | +| `TicketOrchestratorAssign` | `orchestrator_assign` | Explicit delegation/reissue to target principal; required observed nullable `expected_assignment_id`, stable `operation_id` | +| `TicketOrchestratorClear` | `orchestrator_clear` | Explicit release of observed assignment_id with stable operation_id; not Worker lifecycle | +| `TicketAssignmentsQuery` | `assignments_query` | Bounded retained Orchestrator rows, after_assignment_id / next_assignment_id | +| `TicketAssignmentDiagnostics` | `assignment_diagnostics` | Actual sanitized DTO with separate input/start cursors and unknown/history distinctions | +| `TicketAssignmentAnswer` | `assignment_answer` | Same current waiting/inprogress assignment, observed event CAS, stable operation_key, bounded body | + +Reissue is the explicit Assign operation with a new caller-chosen operation identity against the observed old assignment. Exact replay retains the original key, full principal/CAS/input; no tool automatically queries a newer row, rewrites the expected identity or generates a new operation. All descriptions require querying current authority first. Target principal is operation data, never source/actor authority; the tools fix the target role to Orchestrator responsibility, leaving real role/principal/authentication checks in Backend. No worker-start/transfer operation was invented. + +The new endpoints are the actual typed canonical definitions for `assignment-tracking/assignments`, `assignment-diagnostics` and `assignment-answer`. `WorkspaceClient` has no dedicated role-assignment method: the shared typed `WorkspaceServerOperation` forwarding surface was extended in the Worker crate, using actual `server_api` request DTOs and the existing host-bound execution authority. No client-crate extension is needed for these operations, no direct HTTP/storage fallback is used by the tools, and no second assignment/delivery/receipt ledger was introduced. + +`QueryTicket` now accepts the actual typed current `assigned_principal`, `handling_status`, and current closed-case `closure_reason`. Closure reason remains separate from classification tag IDs. New read-only/review surfaces contain retained query and diagnostics, never Assign/clear/answer/self-report mutation. Mutation exposure is additive from the actual manifest authoring/thread/workflow flags; availability is not Backend authorization. + +## Changed paths in this follow-up + +- `crates/worker/src/worker.rs`: shared typed operation forwarding and scalar encoded query transport. +- `crates/worker/src/feature/builtin/ticket.rs`: feature inventories, query schema, native operation mappings and registration. +- `crates/worker/src/feature/builtin/ticket/assignment_public.rs`: new Tools, schemas, input validation and typed bounded/sanitized response handling. +- `crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs`: new boundary/transport/WIP/access/profile fixtures. +- `crates/worker/src/prompt/system.rs`: effective actual-profile research return, explicit confirmation wait, same-row answer and generic close fixtures without repository/MR prerequisites; guarded integration fixture remains separate. +- `resources/prompts/common/tickets.md`: concrete tool names, query-first/CAS/exact replay/actor boundaries. +- `docs/development/ticket-assignment-tracking.md` +- `docs/development/ticket-assignment-delivery.md` +- `docs/development/work-items.md` +- `docs/design/ticket-wip-projection.md` +- this new report. + +## Validation evidence + +Passed focused commands: + +- `cargo test -p worker --lib feature::builtin::ticket::`: **57 passed**, including 12 new public assignment/filter tests. These exercise required observed null/CAS, unknown model authority fields, same-input replay forwarding, read/diagnostic cursor encoding and bounds, answer body/identity validation, stale/permission/unknown outcome non-retry, diagnostic content/credential stripping, ordinary/WIP equivalence and route binding, read-only exclusions, and actual resolved builtin manifest access. +- `cargo test -p worker --lib prompt::system::tests::effective_`: **5 passed**, including the actual resolved-profile no-repo/no-MR research→waiting+answer→reasoned-close fixture and the independent guarded MR-review fixture. +- `cargo test -p worker --lib feature::builtin::orchestration::`: **7 passed**. +- `cargo test -p worker --lib feature::builtin::manage_worker::tests::worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow`: **1 passed**. The prior concurrent macro blocker no longer prevented this test. +- `cargo test -p worker --test feature_invocation_test`: **13 passed**. +- Root `cargo check`: passed at the observed combined snapshot, including Runtime, TUI and Server compile closure. +- Direct delegated-file `rustfmt --check`, `git diff --check HEAD`, and naming check: passed (`558 matching lines; 0 unapproved; 0 stale exceptions`). + +Root `cargo fmt --all -- --check` was **not clean** at the latest observation: non-delegated concurrent edits still needed formatting in `crates/worker-runtime/src/worker_source.rs` and `crates/workspace-server/src/authority.rs`. No outside-scope formatting write was performed. Parent owns the final combined formatting/validation pass. + +## Limits and return + +Transport/schema fixtures use a recording host-bound Workspace adapter; they prove concrete tool wiring, boundary input, permissions and non-retry behavior, not fresh signed Runtime→Server→DB→notification end-to-end execution or model judgment. Backend child's actual persistence/source-fence tests, generated contract synchronization, UI/browser/visual validation, all-Worker/full-stack tests, final combined acceptance and environment rollout remain parent-owned/separate. New API source definitions and one successful root compile are not whole-Ticket satisfaction evidence. No T-743 completion, review request, commits, pushes, merge, Worker stop or backend ledger implementation was performed. diff --git a/docs/report/T-743-prompt-surfaces.md b/docs/report/T-743-prompt-surfaces.md new file mode 100644 index 000000000..6fc1e1c62 --- /dev/null +++ b/docs/report/T-743-prompt-surfaces.md @@ -0,0 +1,64 @@ +# T-743 delegated Prompt / Profile / Flow / Worker handoff + +## Boundaries + +Base: `56795fb92cb9def9bcd701bd51d92e99526f8eff`, branch `work/T-743-ticket-public-surfaces`. Read typed T-743, O-19 and T-739–T-742, assignment tracking/delivery docs, generated OpenAPI and the Rust testing strategy before tests. This report covers only delegated resources/docs/Worker changes, not the parent's UI/Server/API work. No backend ledger, commits, pushes, merge, review request or Ticket conclusion was performed. Existing reports and history were not rewritten. Two historical design documents received only a supersession notice above their preserved original content. + +## Changes + +- Remove ready/intake-ready/Queue from normal Worker Ticket catalogs and native WIP mappings, including all-capability configurations. Remove obsolete queue-response observation invalidation; preserve inverse relation invalidation. Internal trait adapters are not new public operations. +- Align common/role/panel instructions: cases open/closed; stable classification IDs and actual tag/set CAS; assignment waiting/inprogress/done is self-report, independent of Worker/MR/delivery/acceptance/work release. +- Answers during waiting/ongoing handling continue the same assignment; explicit done reissue creates a new row even for the same principal and preserves old results. Reopen and Worker exchange alone are not reissue. Done/close do not require unassignment. +- Explain full-input operation identity/replay, observed assignment/event CAS, stale reread/reconciliation and unknown outcomes without automatic fresh-operation retry. Distinguish saved Assign from failed delivery. Supply observed `orchestrator_assignment_id` for Orchestrator starts, including generic spawn/existing-Worker assignment. +- Preserve explicit user approval conditions and guarded trusted review for MR integration, without a universal MR/human approval gate on generic closure. Flow terminal handoff is not case closure, handling done, work release or Worker stop. +- Add catalog exclusion and effective prompt/schema fixtures for research return, confirmation wait and reasoned closure. These are scripted instruction/schema checks, not model judgment or full-stack behavior tests. + +## Delegated diff paths + +Worker: +- `crates/worker/src/feature/builtin/ticket.rs` +- `crates/worker/src/feature/builtin/ticket/assignment_tracking.rs` +- `crates/worker/src/prompt/system.rs` + +Resources: +- `resources/flows/coder-review.dcdl` +- `resources/profiles/intake.dcdl` +- `resources/profiles/orchestrator.dcdl` +- `resources/prompts/common/tickets.md` +- `resources/prompts/panel/orchestrator_idle_queue_notice.md` +- `resources/prompts/role/coder.md` +- `resources/prompts/role/intake.md` +- `resources/prompts/role/orchestrator.md` +- `resources/prompts/role/ticket-worker.md` + +Current docs / historical-status notices: +- `docs/development/work-items.md` +- `docs/development/ticket-assignment-delivery.md` +- `docs/design/ticket-wip-projection.md` +- `docs/design/merge-request-wip-projection.md` +- `docs/design/ticket-evidence.md` +- `docs/design/flow-state-graph.md` +- `docs/design/workspace-kanban-orchestrator-runtime.md` (notice only) +- `docs/design/orchestrator-worktree-layout.md` (notice only) +- this new report + +## Focused validation evidence + +Passed: +- `cargo test -p worker --lib prompt::system::tests::effective_`: 5 tests (including new return/wait/close schema fixture). +- `cargo test -p worker --lib feature::builtin::ticket::`: 45 tests, including normal/WIP retirement, case CAS, tag schemas/transport, assignment tracking and relation observation tests. +- `cargo test -p worker --lib feature::builtin::orchestration::`: 7 tests, including observed delegation forwarding and resource/Profile boundaries. +- `cargo test -p manifest --lib builtin_profile::tests::`: 4 tests. +- `cargo test -p manifest --lib profile::tests::builtin_orchestrator_keeps_cleanup_tool_providers_enabled`: 1 test. +- `cargo test -p flow --lib builtin::tests::`: 2 tests; built-in recipe compiles and keeps current selector review contracts. +- Root `cargo check`, `cargo fmt --all -- --check`, `git diff --check HEAD`, and naming check (`558 matching lines; 0 unapproved; 0 stale exceptions`) passed at an earlier snapshot before subsequent concurrent Server/API edits. Worker files were formatted again after the final fixture addition; diff check also passed afterward. + +One subsequent Worker spawn-contract invocation was blocked before test execution by a concurrent parent-owned API edit: `crates/server-api/src/lib.rs:3208` (`ticket_assignments_page`) used `#[context] context: ServerRequestContext`; the API macro requires `#[path]`, `#[query]`, `#[header]`, `#[body]`, `#[binary]` or `#[extension]` and rejected it. Command: `cargo test -p worker --lib feature::builtin::manage_worker::tests::worker_lifecycle_spawn_serializes_ticket_assignment_without_workdirs_or_flow`. No test result or final combined-tree compile success is claimed for that invocation. Parent must revalidate its current API/combined tree. + +## Concrete API / runtime limits + +At base `56795fb9`, generated `openapi/server-api.json` has assignment tracking/history and start fences but no assignment-delivery query/receipt HTTP endpoint. `list_pending_ticket_assignment_deliveries` is an internal Store method, not a browser or normal Tool/WIP route. Normal Worker/WIP Ticket inventories also have no Orchestrator Assign/reissue/clear operation; this change removes obsolete Queue rather than fabricating a replacement operation or ledger. The parent owns any public endpoint/Tool handoff needed to satisfy the whole Ticket. Instructions explicitly require reporting unavailable typed operations and forbid inferring delivery/acceptance from status or Worker state. + +The granted live typed Ticket tools still returned legacy six-state projections for the requested reads; those reads established requirements/history, not evidence that the running environment has adopted the repository's new API. No environment update occurred. + +Not run/claimed: whole Worker test suite, Web/browser/visual validation, generated-contract regeneration/check, live migration, model judgment evaluation, full-stack E2E, final combined-tree root compile after concurrent API changes, or T-748 DB-boundary validation. Parent's concurrent Ticket/Server/API/Web changes are excluded from this implementation's evidence. diff --git a/docs/report/T-743-public-surface-validation.md b/docs/report/T-743-public-surface-validation.md new file mode 100644 index 000000000..a63eb4d43 --- /dev/null +++ b/docs/report/T-743-public-surface-validation.md @@ -0,0 +1,62 @@ +> Historical evidence: the user withdrew composition/wait instructions below. +> Current standalone correction and fresh validation/review evidence: +> `T-743-standalone-correction.md`. The original body is retained, not a T-748 return gate. + +# T-743 public surfaces: combined implementation and validation + +## Snapshot and scope + +Work branch: `work/T-743-ticket-public-surfaces`, based on T-742's `56795fb92cb9def9bcd701bd51d92e99526f8eff`. This is implementation evidence, not an integration, approval, Ticket conclusion or live-environment update. See the delegated Prompt and Tool reports in this directory for their narrower evidence and earlier blockers; the checks below supersede their earlier compile/format limitations for the combined snapshot. + +The existing Ticket, assignment tracking, assignment delivery and start records remain the authorities. No browser ledger, alternative assignment store or copied DB executor was added. + +## Implemented boundaries + +- Ticket list and detail use open/closed case state, stable classification tag IDs and an independent current responsibility/handling projection. Tags and current/historical closure reasons remain distinct. List filtering for tag IDs, principal, case and current close reason is server-side and preserves cursors; ordinary text search can match retained closure reasons after correction/reopen. +- The Orchestrator page reads current assignments with waiting/inprogress/done, reasons, progress, next action and results. Worker liveness, MR evidence, delivery and acceptance do not manufacture a handling status. +- Detail supports classification catalog create/list/rename/delete, multiple membership changes, reasoned close/correction/reopen, Assign, observed replacement/reissue, release, retained assignment/result/release history and explicit assignment-fenced answers. Responsibility principal choice uses the real typed user/Worker/Workspace-agent request, not caller authentication data. Backend still validates identities and actual authority. +- A saved Assign is not reported as successful delivery or execution. Unknown/stale/permission/unavailable outcomes are distinct. Exact operation snapshots preserve the original key, observed assignment/event or tag set and body; no automatic new delegation is substituted after a lost response. +- Pending child operations and observations reset on the Workspace **and** Ticket key, including Workspace changes that retain the same Ticket ID. +- Normal Tools/WIP and effective Prompt/Profile/Flow descriptions use Assign as delegation, no ready/Queue prerequisite, same-row continuation answers, explicit new-row reissue and separate case judgment. Retained responsibility and Done are not Worker activity, merge or third-party approval. Historical reports were not rewritten; superseded design descriptions have explicit notices. + +### Canonical API contacts + +Canonical `/workspaces/{workspace_id}/tickets/{ticket_id}` and `/api/w/{workspace_id}/tickets/{ticket_id}` routes expose: + +- GET `assignment-tracking/assignments`: bounded retained rows with assignment-identity pagination. +- GET `assignment-diagnostics`: sanitized existing input/delivery/ack/start records, independent input/start cursors, unknown origin/acceptance/actual-processing distinctions; no rendered notification content or binding credentials. +- POST `assignment-answer`: authenticated source, observed assignment and handling-event CAS, stable operation-key receipt, body event and audit under one IMMEDIATE transaction. Publication occurs only after the helper returns with its transaction committed. Exact replay retains the receipt; generic history comments do not become unfenced continuing input. +- Shared Ticket query principal/handling/current-closure-reason filtering is applied in SQL before paging. + +## Validation + +Passed on the initial T-743 public-surface snapshot (before T-748 composition); later combined execution-boundary evidence is in `T-743-T748-composition.md`: + +- Root `cargo check` (normal TUI/Server compile closure). +- `cargo test --no-fail-fast -p ticket -p worker-runtime -p worker -p yoi-workspace-server -p server-api`: all selected crate targets passed. Library counts: server-api 98; ticket 99; worker 994; worker-runtime 417; workspace-server 946 with one existing ignored test. Included integration/binary targets also passed. Log: `target/t743-changed-crates-final.log`. +- Focused real-Worker delivery fixture now synchronizes on a canonical `RunCompleted` **after** the latest notification plus final Idle, not the initial Idle window, a fixed sleep or acceptance alone. The original pre-processing Idle failure was reproduced and the focused test plus full Server suite passed after the fix. +- Focused manifest builtin-profile (4) and builtin Flow (2) tests passed. Effective research-return, confirmation-wait/same-assignment-answer and separate reasoned-close fixtures are included in the Worker suite. +- Web `deno task test`: **613 passed**. Web `deno task test:component`: **318 passed**. Web check: **0 errors, 0 warnings**. Static production build passed (existing chunk-size advisory remains). +- `ticket_shell_test.ts` plus `ticket_principal_test.ts`: **7 Chromium browser tests passed**. These exercise repository-free creation, server-filter pagination, known/custom close reasons, same-row answer, new-row reissue, retained results/releases, separate close/reopen, stable tag management, ambiguous saved Assign/exact retry, stale and permission denial, and the case/handling/Worker/MR/empty/error matrix in both themes. The native principal-selector test captures the selected target/observed row and waits on the second actual failed request for retry synchronization. Logs: `target/t743-browser-final.log`. +- All six server-api TypeScript generators' `--check` commands passed (ticket, repository, runtime, workdir, Worker launch and companion); generated contract provenance is synchronized with OpenAPI. +- `cargo fmt --all -- --check`, `git diff --check HEAD` and `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts` passed; naming check: **558 matching lines, 0 unapproved, 0 stale exceptions**. + +Earlier validation found and corrected the obsolete TUI queued-notice expectation, two delivery tests expecting ordinary comments to generate new continuing input, and a Home fixture missing the canonical assignment-tracking array. One run also exposed an unchanged Runtime cleanup test's differing walker counts. Its focused invocation and subsequent complete selected-crate run passed; no cleanup implementation fix or claim that this intermittent issue has been eliminated is made. + +## Visual evidence + +Baseline captures: `target/web-ux/t743-baseline-light` and `t743-baseline-dark`. Detail after captures: `t743-after-light` and `t743-after-dark`; actual contact-sheet slices were viewed, not merely generated. Conditions include member/non-owner fixture, Backend action-ineligible controls, 1440/768/390px, light/dark, open+done, open+waiting/stopped Worker, inprogress/running Worker/MR, closed/history, empty, long content and deliberate errors. + +The deliberate query-error route retains its expected HTTP 503/document errors, so the full scenario's exit 2 is not presented as a clean no-error capture. Fixture "anonymous" transport is a synthetic member scenario, not proof of live anonymous authorization. + +Principal-selector captures: `t743-principals-light`/`dark` before a styling correction and `t743-principals-final-light`/`dark` after it. Each final bundle has nine captures (agent/account/Worker choices at all three widths), with no capture errors. The first inspection found cramped native fieldset controls. The fix reuses shared Ticket form styling and removes the redundant fieldset border; final light/dark contact sheets were viewed. Controls, labels and focus remain readable without page overflow. + +List/Orchestrator final evidence: `t743-list-final-reviewed-light` and `t743-list-final-reviewed-dark`, 33 captures each. Parent recovered the child's committed check/build and eight summary-model tests, inspected its final capture metadata (only the intentional query-error route had errors), and viewed actual list/Orchestrator screenshots at desktop/narrow/mobile. The refinements remove repeated offline/count prose, preserve the first mobile case in view and fold close-reason controls separately from classification. Parent verified current diff and browser tests, released the delegated file authority and owns the published handoff. This is observed tool/image evidence, not an invented child review verdict. + +## Explicit remaining work and limits + +The published T-748 source `66bb9d0b825d72e20923003b273b787007f3aed8` has now been normally merged into the same T-743 source and the added callers connected to its existing bounded owner. The answer transaction remains intact; Runtime live discovery and post-commit dispatch use the finite Host lane. Combined full Server/Workdir and ten new boundary regressions pass; see `T-743-T748-composition.md` for exact proof and distinctions. However T-748 MR seq3 has two unresolved major findings on other Subjektiv/network and cold protocol signing paths. A validated upstream fix source, normal composition/revalidation and fresh independent exact combined-source review remain necessary. This is not final approval. + +Current evidence does not attest actual model judgment quality. Prompt/Tool fixtures are scripted inclusion/schema/transport tests; Chromium uses a loopback API fixture. The real-Worker Server fixture uses a scripted model with actual Controller/session/Runtime acceptance and canonical processing synchronization. None is a live migration/rollout, proof of actual processing for every production delivery, or completion of T-744's full real-path validation. + +No target integration, Ticket conclusion, live process update or branch/Workdir cleanup was performed. diff --git a/docs/report/T-743-standalone-correction.md b/docs/report/T-743-standalone-correction.md new file mode 100644 index 000000000..8a94cda88 --- /dev/null +++ b/docs/report/T-743-standalone-correction.md @@ -0,0 +1,139 @@ +# T-743 standalone source correction + +## Accepted-plan correction and source boundary + +The user withdrew the instruction to compose/wait for T-748. T-743 owns its +public UI/API/Tools/Prompt work and its own independent review/fix loop against +the existing develop contracts. Platform composition and target integration are +Orchestrator work. T-748 implementation or approval is **not** a T-743 return +gate. This does not reduce any T-743 product requirement or authorize ignoring +T-743-specific authorization, concurrency or public-operation defects. + +Observed develop is `56795fb92cb9def9bcd701bd51d92e99526f8eff`; a normal fetch +confirmed no movement from the original T-743 base. Standalone implementation +`9b0d42fdbc2920736c3418bb894f26140e962bbc` adds the required public contracts and +semantic tests on that base. Published merge `5c5727e3f8ad85bddebda2672fd245a24317fc5f` +had added unrelated T-748 platform and dependent adapters at the withdrawn +instruction. Corrective commit `c6d73e82` uses a **normal mainline revert** of that +merge, not reset, rebase, force push or a replacement MR. + +Before retaining historical report bodies, the corrective tree was compared and +matched `9b0d42fd` exactly. After retaining those reports, `git diff --quiet +9b0d42fd -- crates web tools resources openapi` still passed. Thus all source, +wire/generated contracts, UI/Tools/Prompt and semantic tests from the initial +standalone result survive without changes or weakened assertions. + +Removed only the composition's shared DB/Host/cleanup execution owners, broad +async-platform and lifecycle caller changes, Workdir signing adapter, and the +new assignment/query execution adapters/test accessors and fixtures requiring +that removed owner. No incomplete executor facade or platform API was copied. +The baseline's synchronous execution placement is distinguished from T-743 +product defects; it is not an excuse to overlook a newly introduced defect. +The implementation is separable: no T-743 product API, author/source fence or +receipt requires a T-748-only type. + +Kept T-743's authenticated source attribution, current singleton/live binding +fence including replay, whole IMMEDIATE answer source/CAS/body/audit/receipt/ +commit transaction, committed-input delivery scan/deduplication, sanitized +assignment diagnostics, retained history, typed SQL-before-page filters, +canonical/Browser routes and Normal Tools/WIP transport. Kept case/handling/ +Worker/MR separation, same-assignment answer versus new-row reissue, stable tags +and closure history, effective Prompt/Profile/Flow and their original tests. + +## Evidence and review ownership + +Historical report bodies (`T-743-public-surface-validation.md`, +`T-743-T748-callers.md`, `T-743-T748-composition.md`), published commits and MR/ +Ticket events remain evidence of what was done then. Their old composition-wait +instructions are superseded, not retroactively rewritten. T-748-specific reports +and tests remain readable in the published merge's Git history, but are not +current T-743 source contracts. This document carries the current correction and +new validation evidence. + +Fresh trusted independent review on MR +`01a12d19-b61b-79a2-baca-89241fd3c3d3` judges the actual T-743 Ticket requirements, +authorization, concurrency and public operations against the exact normally +published corrected source. The review thread should include this correction +as an append-only event, not replace its original summary. The Coder tool set +has no MR-comment mutation tool; `OpenMergeRequest` on this repository would +conflict with the existing MR, not append a comment. The correction is therefore +supplied for the trusted review event body, without bypassing typed authority. +No target integration, Ticket/MR conclusion, live update or retained Worker/ +Workdir cleanup is authorized or performed. + +## Revalidation + +These are fresh results on the corrected standalone implementation, not the +historical T-748-composed suite. Only report notices/evidence changed afterward; +implementation equality with `9b0d42fd` was checked again before publication. + +- Root `cargo check` passed, including normal TUI/Server compile closure; final + repeat passed. Logs: `target/t743-standalone-{check,final-check}.log`. +- Public assignment/source/replay/filter focused tests: **28 passed**. +- `cargo test --no-fail-fast -p ticket -p worker-runtime -p worker -p yoi-workspace-server -p server-api` + passed all selected targets with normal default parallelism. Library counts: + server-api **98**, ticket **99**, worker **994**, worker-runtime **417**, + Server **946** with **1 pre-existing ignored** (176.76s); Server binary **11**, + other integration/binary/doc targets passed. Log: `target/t743-standalone-crates.log`. +- The full Worker suite includes public Tool/WIP forwarding, source/target + distinction, exact-retry and effective research-return/confirmation-wait/ + separate-close Prompt fixtures. These are scripted semantic tests, not a + measurement of real model judgment. Extra `manifest --lib builtin_profile` + **6 passed** and `worker --lib flow` **17 passed**. Initial mistyped filters + matched zero tests and are not counted as proof. +- Fresh Web check **0 errors/0 warnings** and production build passed; existing + chunk-size advisory remains. Web **613**, component **318** and Chromium + Ticket/principal **7** tests passed. Logs: `target/t743-standalone-{web-check,web-build,web-tests,components,browser}.log`. +- All six TypeScript generator `--check` commands passed: ticket, repository + OpenAPI, runtime, workdir, Worker launch and companion. +- `cargo fmt --all -- --check`, `git diff --check HEAD` and the required + `env -u LD_LIBRARY_PATH deno run --allow-read --allow-run=git tools/check-state-contract-names.ts` + passed: **558 matching lines, 0 unapproved, 0 stale exceptions**. + +## Recovered visual evidence and parent review + +The visual child was stopped by an interrupted turn, not a successful final +handoff. The parent inspected its committed session and recovered six completed +capture bundles under `target/t743-standalone-visual`, then personally used +`ViewImage` on actual images. No missing child verdict was invented, and no +child was restarted just to wait for a report. + +Fresh bundles at source **`c6d73e8286d743e0329154d49b52d4bee53e8d60`**: + +- `c6d73e82-tickets` / `tickets-dark`: **63 captures each**, covering detail, + list/create/current closure filtering, Orchestrator, open+done, waiting, + running/stopped Worker, repository/MR/no-MR, read-only/eligibility, closed, + long, empty, error and unknown states at **1440/768/390**, light/dark. +- `c6d73e82-ticket-principals` / `ticket-principals-dark`: **9 each**, explicit + agent/account/Worker selection at all three widths. +- `c6d73e82-related-light` / `related-dark`: **21 each**, explicitly scrolled + capture points for retained result/release history, case history, returned + handling, stopped/running Worker, action eligibility and MR separation. + This closes the inner-scroll gap: browser full-page capture alone does not + expose lower detail sections. + +Total **186** captures. Metadata records the actual source SHA and `dirty:true` +for the then-uncommitted report notices, not hidden implementation changes. +Parent rechecked the implementation equality, source/viewport/route matrix, +per-capture errors and runner-owned fixture shutdown records. Parent actual image +review included list desktop/mobile and Orchestrator narrow/mobile, principal +light/mobile and dark/desktop sheets, waiting and returned handling, retained +result/release mobile and case history narrow, stopped Worker, MR separation, +mobile error and long list. Labels/actions remain readable; case state, handling, +actual Worker and MR sections remain distinct; no new overflow or visual fix was +needed. Original before/after visual evidence remains retained for comparison. + +Both Ticket scenario bundles deliberately exit **2** and are +`completed-with-errors`: only the `error` route at the three widths records its +fixture query **HTTP503**, console/request-abort and visible error/Retry evidence. +The other four bundles exit **0**, and no other route has capture errors. The +parent saved the exact error audit in +`target/t743-standalone-visual/parent-errors-audit.txt`. Do not describe the whole +scenario as error-free or the intentional 503 as a product regression. The +synthetic member/anonymous transport is not proof of live anonymous permission; +actual cookie Origin/signed Runtime/source denial is exercised by Server tests. + +All reported fixture processes had owner-scoped shutdown before the child was +stopped; this is fixture teardown, not live Yoi/retained Worker cleanup. This +publication is ready for its own fresh independent T-743 review, not an approval, +model-quality attestation, completion of T-744 live paths or target integration. diff --git a/openapi/server-api.json b/openapi/server-api.json index acb68c9ac..2e06fac1e 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -69,6 +69,12 @@ }, "type": "array" }, + "Array_of_TicketAssignmentTrackingProjection": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, "Array_of_TicketRelation": { "items": { "$ref": "#/components/schemas/TicketRelation" @@ -81,6 +87,12 @@ }, "type": "array" }, + "Array_of_WorkspaceTag": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "AttachmentUploadCancelResponse": { "additionalProperties": false, "properties": { @@ -298,30 +310,6 @@ ], "type": "object" }, - "BrowserCloseTicketRequest": { - "additionalProperties": false, - "properties": { - "expected_content_digest": { - "type": "string" - }, - "expected_state": { - "$ref": "#/components/schemas/BrowserTicketWorkflowState" - }, - "operation_key": { - "type": "string" - }, - "resolution": { - "type": "string" - } - }, - "required": [ - "operation_key", - "expected_content_digest", - "expected_state", - "resolution" - ], - "type": "object" - }, "BrowserCreateWorkerResponse": { "additionalProperties": false, "properties": { @@ -411,6 +399,13 @@ "additionalProperties": false, "type": "object" }, + "BrowserTicketState": { + "enum": [ + "open", + "closed" + ], + "type": "string" + }, "BrowserTicketTargetEdit": { "oneOf": [ { @@ -457,51 +452,6 @@ ], "type": "string" }, - "BrowserTicketWorkflowState": { - "enum": [ - "planning", - "ready", - "queued", - "inprogress", - "done", - "closed" - ], - "type": "string" - }, - "BrowserTransitionTicketStateRequest": { - "additionalProperties": false, - "properties": { - "body": { - "type": [ - "string", - "null" - ] - }, - "expected_content_digest": { - "type": "string" - }, - "expected_state": { - "$ref": "#/components/schemas/BrowserTicketWorkflowState" - }, - "operation_key": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "state": { - "$ref": "#/components/schemas/BrowserTicketWorkflowState" - } - }, - "required": [ - "state", - "operation_key", - "expected_content_digest", - "expected_state", - "reason" - ], - "type": "object" - }, "BrowserWorkerWorkingDirectorySelection": { "additionalProperties": false, "properties": { @@ -1091,37 +1041,6 @@ ], "type": "object" }, - "CompleteTicketRequest": { - "additionalProperties": false, - "properties": { - "expected_content_digest": { - "type": "string" - }, - "expected_state": { - "$ref": "#/components/schemas/TicketWorkflowState" - }, - "operation_key": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "references": { - "default": [], - "items": { - "$ref": "#/components/schemas/TicketReference" - }, - "type": "array" - } - }, - "required": [ - "operation_key", - "expected_content_digest", - "expected_state", - "reason" - ], - "type": "object" - }, "CompletionContext": { "properties": { "argument": { @@ -1687,6 +1606,13 @@ "operation_id": { "type": "string" }, + "orchestrator_assignment_id": { + "description": "Observed delegation required for Orchestrator Ticket starts; manual starts omit it.", + "type": [ + "string", + "null" + ] + }, "ticket_id": { "type": "string" } @@ -5633,7 +5559,7 @@ "workflow_state": { "anyOf": [ { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState" }, { "type": "null" @@ -9034,6 +8960,13 @@ "operation_id": { "type": "string" }, + "orchestrator_assignment_id": { + "description": "Observed delegation required for Orchestrator Ticket starts; manual starts omit it.", + "type": [ + "string", + "null" + ] + }, "ticket_id": { "type": "string" } @@ -9385,6 +9318,13 @@ "operation_id": { "type": "string" }, + "orchestrator_assignment_id": { + "description": "Observed delegation required for Orchestrator Ticket starts; manual starts omit it.", + "type": [ + "string", + "null" + ] + }, "principal": { "$ref": "#/components/schemas/TicketAssignmentPrincipal" }, @@ -10512,6 +10452,27 @@ ], "type": "string" }, + "TagListRequest": { + "description": "Active tags in ascending ID order, strictly after `after`; default/max 200.\nLarger limits are clamped to 200; zero returns an empty page.", + "properties": { + "after": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 200, + "minimum": 0, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, "Ticket": { "properties": { "artifacts": { @@ -10520,6 +10481,29 @@ }, "type": "array" }, + "case_history": { + "items": { + "$ref": "#/components/schemas/TicketCaseHistoryEntry" + }, + "type": "array" + }, + "closure_operation_key": { + "description": "Last closure/correction operation key; retained when reopened (ABA guard).", + "type": [ + "string", + "null" + ] + }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "document": { "$ref": "#/components/schemas/TicketDocument" }, @@ -10529,6 +10513,16 @@ }, "type": "array" }, + "legacy_workflow_snapshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketLegacyWorkflowSnapshot" + }, + { + "type": "null" + } + ] + }, "meta": { "$ref": "#/components/schemas/TicketMeta" }, @@ -10544,6 +10538,13 @@ "type": "null" } ] + }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" } }, "required": [ @@ -10551,7 +10552,8 @@ "document", "events", "artifacts", - "relations" + "relations", + "case_history" ], "type": "object" }, @@ -10605,6 +10607,171 @@ ], "type": "object" }, + "TicketAssignmentAnswer": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "type": "string" + }, + "body": { + "type": "string" + }, + "expected_last_event_id": { + "type": "string" + }, + "operation_key": { + "type": "string" + } + }, + "required": [ + "assignment_id", + "expected_last_event_id", + "operation_key", + "body" + ], + "type": "object" + }, + "TicketAssignmentAnswerReceipt": { + "properties": { + "assignment_id": { + "type": "string" + }, + "event_index": { + "format": "uint64", + "maximum": 9007199254740991, + "minimum": 0, + "type": "integer" + }, + "input_event_id": { + "type": "string" + } + }, + "required": [ + "assignment_id", + "input_event_id", + "event_index" + ], + "type": "object" + }, + "TicketAssignmentDiagnosticsPage": { + "properties": { + "actionable": { + "description": "Noncurrent/done/closed/released records are history, not pending work.", + "type": "boolean" + }, + "assignment_id": { + "type": "string" + }, + "current": { + "type": "boolean" + }, + "input_origin_known": { + "type": "boolean" + }, + "inputs": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentInputDiagnostic" + }, + "type": "array" + }, + "next_input_event_id": { + "type": [ + "string", + "null" + ] + }, + "next_start_operation_id": { + "type": [ + "string", + "null" + ] + }, + "starts": { + "description": "Assignment-scoped admission evidence; not attributed to individual input.", + "items": { + "$ref": "#/components/schemas/TicketAssignmentStartDiagnostic" + }, + "type": "array" + } + }, + "required": [ + "assignment_id", + "current", + "input_origin_known", + "actionable", + "inputs", + "starts" + ], + "type": "object" + }, + "TicketAssignmentDiagnosticsQuery": { + "additionalProperties": false, + "properties": { + "after_input_event_id": { + "type": [ + "string", + "null" + ] + }, + "after_start_operation_id": { + "type": [ + "string", + "null" + ] + }, + "assignment_id": { + "type": "string" + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "assignment_id" + ], + "type": "object" + }, + "TicketAssignmentInputDeliveryState": { + "enum": [ + "not_reserved", + "reserved", + "request_persisted", + "runtime_accepted" + ], + "type": "string" + }, + "TicketAssignmentInputDiagnostic": { + "properties": { + "acceptance_outcome_unknown": { + "description": "Absence of a Runtime ack is not proof of failed external delivery.", + "type": "boolean" + }, + "delivery_id": { + "type": [ + "string", + "null" + ] + }, + "input_event_id": { + "type": "string" + }, + "notification": { + "$ref": "#/components/schemas/TicketAssignmentInputDeliveryState" + } + }, + "required": [ + "input_event_id", + "notification", + "acceptance_outcome_unknown" + ], + "type": "object" + }, "TicketAssignmentPrincipal": { "oneOf": [ { @@ -10664,6 +10831,122 @@ } ] }, + "TicketAssignmentPrincipalSnapshot": { + "description": "Immutable principal as stored at Assign, not live Worker availability.", + "oneOf": [ + { + "properties": { + "account_id": { + "type": "string" + }, + "kind": { + "const": "user", + "type": "string" + } + }, + "required": [ + "kind", + "account_id" + ], + "type": "object" + }, + { + "properties": { + "kind": { + "const": "worker", + "type": "string" + }, + "runtime_id": { + "type": "string" + }, + "worker_id": { + "type": "string" + } + }, + "required": [ + "kind", + "runtime_id", + "worker_id" + ], + "type": "object" + }, + { + "properties": { + "agent_key": { + "type": "string" + }, + "kind": { + "const": "workspace_agent", + "type": "string" + } + }, + "required": [ + "kind", + "agent_key" + ], + "type": "object" + } + ] + }, + "TicketAssignmentRelease": { + "description": "Release is an independent immutable assignment audit event. Last tracking\nremains unchanged, including unknown legacy tracking; it is never made done.", + "properties": { + "action": { + "$ref": "#/components/schemas/TicketAssignmentReleaseAction" + }, + "actor": { + "type": "string" + }, + "at": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "last_tracking": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentTracking" + }, + { + "type": "null" + } + ] + }, + "operation_key": { + "type": [ + "string", + "null" + ] + }, + "reason": { + "type": [ + "string", + "null" + ] + }, + "replacement_assignment_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "action", + "event_id", + "actor", + "at" + ], + "type": "object" + }, + "TicketAssignmentReleaseAction": { + "enum": [ + "unassigned", + "replaced" + ], + "type": "string" + }, "TicketAssignmentRole": { "enum": [ "orchestrator", @@ -10673,6 +10956,45 @@ ], "type": "string" }, + "TicketAssignmentStartDiagnostic": { + "properties": { + "actual_processing_unknown": { + "description": "Existing records do not attest that a model processed a particular input.", + "type": "boolean" + }, + "assignment_id": { + "type": [ + "string", + "null" + ] + }, + "claim_state": { + "description": "Durable start/assignment reservation result, not Worker liveness.", + "type": "string" + }, + "operation_id": { + "type": "string" + }, + "runtime_id": { + "type": [ + "string", + "null" + ] + }, + "worker_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "operation_id", + "claim_state", + "actual_processing_unknown" + ], + "type": "object" + }, "TicketAssignmentSummary": { "additionalProperties": false, "properties": { @@ -10699,6 +11021,294 @@ ], "type": "object" }, + "TicketAssignmentTracking": { + "additionalProperties": false, + "properties": { + "next_action": { + "type": [ + "string", + "null" + ] + }, + "progress": { + "type": [ + "string", + "null" + ] + }, + "result": { + "type": [ + "string", + "null" + ] + }, + "status": { + "$ref": "#/components/schemas/TicketAssignmentTrackingStatus" + }, + "waiting_reason": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "TicketAssignmentTrackingHistoryEntry": { + "properties": { + "at": { + "type": "string" + }, + "event_id": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "source_context": true, + "tracking": { + "$ref": "#/components/schemas/TicketAssignmentTracking" + } + }, + "required": [ + "event_id", + "operation_key", + "tracking", + "source_context", + "at" + ], + "type": "object" + }, + "TicketAssignmentTrackingHistoryPage": { + "additionalProperties": false, + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingHistoryEntry" + }, + "type": "array" + }, + "next_event_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "TicketAssignmentTrackingHistoryQuery": { + "additionalProperties": false, + "properties": { + "after_event_id": { + "type": [ + "string", + "null" + ] + }, + "assignment_id": { + "type": "string" + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "required": [ + "assignment_id" + ], + "type": "object" + }, + "TicketAssignmentTrackingProjection": { + "description": "Enrichment is separate from the established assignment record. `None`\ntracking means a retained historical relationship with insufficient delegation\nevidence, not waiting/done. Removal/replacement does not alter last tracking.", + "properties": { + "assigned_at": { + "type": "string" + }, + "assigned_by": { + "type": "string" + }, + "assignment_id": { + "type": "string" + }, + "current": { + "type": "boolean" + }, + "last_event_id": { + "description": "Tracking event, or the existing immutable Assign audit observation for a\nretained current relationship with no tracking yet. This baseline does\nnot imply a handling status, delegation intent, or tracking timestamp.", + "type": [ + "string", + "null" + ] + }, + "migration_diagnostic": { + "type": [ + "string", + "null" + ] + }, + "principal": { + "$ref": "#/components/schemas/TicketAssignmentPrincipalSnapshot" + }, + "release": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentRelease" + }, + { + "type": "null" + } + ] + }, + "role": { + "type": "string" + }, + "ticket_id": { + "type": "string" + }, + "tracking": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentTracking" + }, + { + "type": "null" + } + ] + }, + "updated_at": { + "type": [ + "string", + "null" + ] + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "ticket_id", + "assignment_id", + "role", + "principal", + "assigned_by", + "assigned_at", + "current" + ], + "type": "object" + }, + "TicketAssignmentTrackingQuery": { + "additionalProperties": false, + "properties": { + "assignment_id": { + "description": "Omit for the current delegation; provide to inspect a retained assignment.", + "type": [ + "string", + "null" + ] + } + }, + "type": "object" + }, + "TicketAssignmentTrackingStatus": { + "enum": [ + "waiting", + "inprogress", + "done" + ], + "type": "string" + }, + "TicketAssignmentTrackingUpdate": { + "additionalProperties": false, + "description": "Replaces the complete handling payload against this delegation's observed event,\nnot status alone. Exact retry compares full input and authenticated sender/binding.", + "properties": { + "assignment_id": { + "type": "string" + }, + "expected_last_event_id": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "tracking": { + "$ref": "#/components/schemas/TicketAssignmentTracking" + } + }, + "required": [ + "assignment_id", + "expected_last_event_id", + "operation_key", + "tracking" + ], + "type": "object" + }, + "TicketAssignmentsPage": { + "properties": { + "items": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, + "next_assignment_id": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "items" + ], + "type": "object" + }, + "TicketAssignmentsQuery": { + "additionalProperties": false, + "properties": { + "after_assignment_id": { + "type": [ + "string", + "null" + ] + }, + "limit": { + "format": "uint", + "maximum": 100, + "minimum": 1, + "type": [ + "integer", + "null" + ] + } + }, + "type": "object" + }, + "TicketAttention": { + "enum": [ + "unresolved_review", + "missing_commit", + "blocked", + "unblocked", + "awaiting_review", + "unresolved_changes", + "stale_after_rescope", + "missing_evidence" + ], + "type": "string" + }, "TicketBodyReplacement": { "properties": { "new_string": { @@ -10718,12 +11328,106 @@ ], "type": "object" }, - "TicketCompletion": { - "additionalProperties": false, - "description": "Audited completion independent of repositories, Git, or Merge Requests.", + "TicketCaseAction": { + "description": "Close requires an open case; reopen and correction require a closed case.\nReopen clears the current judgment but retains its history and last closure key.\nCorrection appends a new judgment without rewriting the old one.", + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "action": { + "const": "close", + "type": "string" + }, + "reason": { + "$ref": "#/components/schemas/TicketClosureReason" + } + }, + "required": [ + "action", + "reason" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "action": { + "const": "reopen", + "type": "string" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "action": { + "const": "correct_closure", + "type": "string" + }, + "reason": { + "$ref": "#/components/schemas/TicketClosureReason" + } + }, + "required": [ + "action", + "reason" + ], + "type": "object" + } + ] + }, + "TicketCaseHistoryEntry": { + "description": "Immutable case decision, including reopening and corrections. A correction\nadds a new judgment; it never overwrites the old reason, actor or references.", "properties": { + "action": { + "$ref": "#/components/schemas/TicketCaseAction" + }, + "at": { + "type": "string" + }, "author": { - "default": null, + "type": "string" + }, + "explanation": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "previous_closure_operation_key": { + "type": [ + "string", + "null" + ] + }, + "references": { + "items": { + "$ref": "#/components/schemas/TicketReference" + }, + "type": "array" + } + }, + "required": [ + "operation_key", + "action", + "explanation", + "references", + "author", + "at" + ], + "type": "object" + }, + "TicketCaseUpdateRequest": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/TicketCaseAction" + }, + "expected_closure_operation_key": { "type": [ "string", "null" @@ -10733,12 +11437,12 @@ "type": "string" }, "expected_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState" }, - "operation_key": { + "explanation": { "type": "string" }, - "reason": { + "operation_key": { "type": "string" }, "references": { @@ -10752,11 +11456,73 @@ "required": [ "expected_content_digest", "expected_state", + "expected_closure_operation_key", "operation_key", - "reason" + "action", + "explanation" ], "type": "object" }, + "TicketClosureJudgment": { + "properties": { + "at": { + "type": "string" + }, + "author": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "reason": { + "$ref": "#/components/schemas/TicketClosureReason" + }, + "references": { + "items": { + "$ref": "#/components/schemas/TicketReference" + }, + "type": "array" + } + }, + "required": [ + "operation_key", + "reason", + "explanation", + "references", + "author", + "at" + ], + "type": "object" + }, + "TicketClosureReason": { + "description": "Reason attached to a closure judgment, not a classification tag or success inference.\nOnly `Completed` asserts fulfillment; the other reasons can end an unfulfilled case.\n`Other` must be nonblank, at most 1024 UTF-8 bytes, and contain no NUL.", + "oneOf": [ + { + "enum": [ + "completed", + "not_planned", + "duplicate", + "unknown" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": [ + "other" + ], + "type": "object" + } + ] + }, "TicketDependencyCheck": { "properties": { "blockers": { @@ -10814,6 +11580,12 @@ }, "type": "array" }, + "assignment_tracking": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, "assignments": { "items": { "$ref": "#/components/schemas/TicketRoleAssignmentSummary" @@ -10826,6 +11598,19 @@ "body_truncated": { "type": "boolean" }, + "closure_history": { + "items": { + "$ref": "#/components/schemas/TicketCaseHistoryEntry" + }, + "type": "array" + }, + "closure_operation_key": { + "description": "Last closure/correction key, retained across reopening for ABA-safe CAS.", + "type": [ + "string", + "null" + ] + }, "content_digest": { "type": "string" }, @@ -10835,6 +11620,16 @@ "null" ] }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "current_worker": { "anyOf": [ { @@ -10872,6 +11667,17 @@ }, "type": "array" }, + "legacy_handoff": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketLegacyWorkflowSnapshot" + }, + { + "type": "null" + } + ], + "description": "Original migration handoff; not another current state authority." + }, "linked_objectives": { "items": { "$ref": "#/components/schemas/ObjectiveLinkSummary" @@ -10937,7 +11743,13 @@ "type": "array" }, "state": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" + }, + "tags": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" }, "targets": { "items": { @@ -10957,6 +11769,7 @@ }, "required": [ "id", + "tags", "resource_key", "title", "state", @@ -10975,11 +11788,13 @@ "linked_objectives", "implementation_reports", "assignments", + "assignment_tracking", "assignment_diagnostics", "action_eligibility", "merge_requests", "evidence", - "record_source" + "record_source", + "closure_history" ], "type": "object" }, @@ -11393,6 +12208,7 @@ "decision", "implementation_report", "state_changed", + "tags_changed", "intake_summary", "status_changed", "close" @@ -11618,6 +12434,44 @@ }, "type": "object" }, + "TicketLegacyWorkflowSnapshot": { + "description": "Original migration snapshot for downstream routing/tracking handoff only.\nThis is not a second current state authority.", + "properties": { + "queued_at": { + "type": [ + "string", + "null" + ] + }, + "queued_by": { + "type": [ + "string", + "null" + ] + }, + "resolution": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "workflow_state": { + "type": "string" + }, + "workflow_state_explicit": { + "type": "boolean" + } + }, + "required": [ + "workflow_state", + "workflow_state_explicit", + "status" + ], + "type": "object" + }, "TicketListHttpQuery": { "additionalProperties": false, "properties": { @@ -11648,9 +12502,37 @@ "TicketListItemSummary": { "additionalProperties": false, "properties": { + "closure_operation_key": { + "description": "Last closure/correction key, retained across reopening for ABA-safe CAS.", + "type": [ + "string", + "null" + ] + }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "id": { "type": "string" }, + "legacy_handoff": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketLegacyWorkflowSnapshot" + }, + { + "type": "null" + } + ], + "description": "Original migration handoff; not another current state authority." + }, "priority": { "type": "string" }, @@ -11673,7 +12555,14 @@ "type": "string" }, "state": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" + }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" }, "title": { "type": "string" @@ -11936,7 +12825,8 @@ ] }, "workflow_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState", + "description": "Case state only; field name retained temporarily, never execution progress." }, "workflow_state_explicit": { "type": "boolean" @@ -11986,12 +12876,25 @@ "TicketQueryItem": { "additionalProperties": false, "properties": { + "assignment_tracking": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, "blocker_count": { "format": "uint", "maximum": 9007199254740991, "minimum": 0, "type": "integer" }, + "closure_operation_key": { + "description": "Last closure/correction key, retained across reopening for ABA-safe CAS.", + "type": [ + "string", + "null" + ] + }, "content_digest": { "type": "string" }, @@ -12001,6 +12904,16 @@ "null" ] }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "current_worker": { "anyOf": [ { @@ -12017,6 +12930,17 @@ "id": { "type": "string" }, + "legacy_handoff": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketLegacyWorkflowSnapshot" + }, + { + "type": "null" + } + ], + "description": "Original migration handoff; not another current state authority." + }, "linked_objective_ids": { "items": { "type": "string" @@ -12086,7 +13010,13 @@ ] }, "state": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" + }, + "tags": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" }, "title": { "type": "string" @@ -12115,6 +13045,7 @@ }, "required": [ "id", + "tags", "resource_key", "title", "state", @@ -12128,6 +13059,7 @@ "blocker_count", "unresolved_blocker_count", "unresolved_review_count", + "assignment_tracking", "evidence", "merge_requests" ], @@ -12136,13 +13068,35 @@ "TicketQueryRequest": { "additionalProperties": false, "properties": { + "assigned_principal": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentPrincipalSnapshot" + }, + { + "type": "null" + } + ], + "description": "Exact current assigned principal; retained relationships do not match." + }, "attention": { "default": [], "items": { - "type": "string" + "$ref": "#/components/schemas/TicketAttention" }, "type": "array" }, + "closure_reason": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureReason" + }, + { + "type": "null" + } + ], + "description": "Exact reason of the current closed-case judgment; reopened/history-only reasons do not match." + }, "cursor": { "type": [ "string", @@ -12163,6 +13117,17 @@ }, "type": "array" }, + "handling_status": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketAssignmentTrackingStatus" + }, + { + "type": "null" + } + ], + "description": "Explicit self-reported handling only; never inferred from Worker state." + }, "limit": { "format": "uint", "maximum": 1000, @@ -12210,6 +13175,30 @@ }, "states": { "default": [], + "items": { + "$ref": "#/components/schemas/TicketState" + }, + "type": "array" + }, + "tag_ids": { + "default": [], + "description": "Match tickets containing every supplied tag ID (AND).", + "items": { + "type": "string" + }, + "type": "array" + }, + "tag_ids_any": { + "default": [], + "description": "Match tickets containing at least one supplied tag ID (OR).", + "items": { + "type": "string" + }, + "type": "array" + }, + "tag_ids_none": { + "default": [], + "description": "Exclude tickets containing any supplied tag ID (NOT).", "items": { "type": "string" }, @@ -12371,7 +13360,7 @@ "TicketRelationBlocker": { "properties": { "blocking_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState" }, "blocking_ticket": { "type": "string" @@ -12684,48 +13673,12 @@ } ] }, - "TicketStateUpdate": { - "additionalProperties": false, - "description": "External state mutation with content/state CAS and an immutable operation receipt.\nExact replay returns the current authoritative Ticket without a new event or\nreapplying the recorded state, even after later edits or reopening.\nSetting `state` to Closed also stores `reason` as the resolution; reopening\nchanges Ticket metadata only and never revives released work identities.", - "properties": { - "author": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "expected_content_digest": { - "type": "string" - }, - "expected_state": { - "$ref": "#/components/schemas/TicketWorkflowState" - }, - "operation_key": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "references": { - "default": [], - "items": { - "$ref": "#/components/schemas/TicketReference" - }, - "type": "array" - }, - "state": { - "$ref": "#/components/schemas/TicketWorkflowState" - } - }, - "required": [ - "expected_content_digest", - "expected_state", - "state", - "operation_key", - "reason" + "TicketState": { + "enum": [ + "open", + "closed" ], - "type": "object" + "type": "string" }, "TicketStatus": { "enum": [ @@ -12781,6 +13734,13 @@ "status": { "$ref": "#/components/schemas/ExtensibleTicketStatus" }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -12791,7 +13751,8 @@ ] }, "workflow_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState", + "description": "Case state only; field name retained temporarily, never execution progress." }, "workflow_state_explicit": { "type": "boolean" @@ -12831,6 +13792,33 @@ }, "type": "object" }, + "TicketTagSetRequest": { + "additionalProperties": false, + "description": "Compare entire active ID set, then replace it atomically. IDs must be unique;\ninput order is immaterial to CAS, but remains significant for exact replay.", + "properties": { + "expected_tag_ids": { + "items": { + "type": "string" + }, + "type": "array" + }, + "operation_key": { + "type": "string" + }, + "tag_ids": { + "items": { + "type": "string" + }, + "type": "array" + } + }, + "required": [ + "expected_tag_ids", + "tag_ids", + "operation_key" + ], + "type": "object" + }, "TicketTarget": { "additionalProperties": false, "properties": { @@ -12916,17 +13904,6 @@ ], "type": "object" }, - "TicketWorkflowState": { - "enum": [ - "planning", - "ready", - "queued", - "in_progress", - "done", - "closed" - ], - "type": "string" - }, "TicketWorkspaceNextAction": { "enum": [ "clarify", @@ -15933,6 +16910,117 @@ ], "type": "object" }, + "WorkspaceTag": { + "description": "Workspace-scoped immutable identity with a mutable display name.\nNames are Unicode-whitespace trimmed, limited to 128 UTF-8 bytes, and\ndeduplicated among active tags using Rust Unicode lowercase (not full case\nfolding or NFC/NFKC). Empty names and any input control characters are invalid.", + "properties": { + "deleted": { + "type": "boolean" + }, + "id": { + "type": "string" + }, + "last_operation_key": { + "type": "string" + }, + "name": { + "type": "string" + }, + "workspace_id": { + "type": "string" + } + }, + "required": [ + "workspace_id", + "id", + "name", + "last_operation_key", + "deleted" + ], + "type": "object" + }, + "WorkspaceTagAction": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "kind": { + "const": "create", + "type": "string" + }, + "name": { + "type": "string" + } + }, + "required": [ + "kind", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expected_last_operation_key": { + "type": "string" + }, + "kind": { + "const": "rename", + "type": "string" + }, + "name": { + "type": "string" + }, + "tag_id": { + "type": "string" + } + }, + "required": [ + "kind", + "tag_id", + "expected_last_operation_key", + "name" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "expected_last_operation_key": { + "type": "string" + }, + "kind": { + "const": "delete", + "type": "string" + }, + "tag_id": { + "type": "string" + } + }, + "required": [ + "kind", + "tag_id", + "expected_last_operation_key" + ], + "type": "object" + } + ] + }, + "WorkspaceTagMutation": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/WorkspaceTagAction" + }, + "operation_key": { + "type": "string" + } + }, + "required": [ + "operation_key", + "action" + ], + "type": "object" + }, "WorkspaceWorkerDiscoveryItem": { "additionalProperties": false, "description": "Bounded, model-safe projection used by privileged Workspace Worker discovery.\nRuntime placement appears only in the typed subject required by Worker\ncontrol operations; provider and launch internals are intentionally omitted.", @@ -16026,7 +17114,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238" + "x-yoi-source-digest": "sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb" }, "openapi": "3.1.0", "paths": { @@ -32604,6 +33692,208 @@ ] } }, + "/api/w/{workspace_id}/ticket-tags": { + "get": { + "operationId": "workspace_ticket_tag_list", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "request", + "required": true, + "schema": { + "$ref": "#/components/schemas/TagListRequest" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_WorkspaceTag" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "workspace_ticket_tag_mutate", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceTagMutation" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/WorkspaceTag" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/tickets": { "get": { "operationId": "ticket_list", @@ -33939,260 +35229,6 @@ ] } }, - "/api/w/{workspace_id}/tickets/{id}/close": { - "post": { - "operationId": "ticket_close", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrowserCloseTicketRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketDetail" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/complete": { - "post": { - "operationId": "ticket_complete", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CompleteTicketRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Ticket" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, "/api/w/{workspace_id}/tickets/{id}/dependency-check": { "get": { "operationId": "ticket_dependency_check", @@ -35635,9 +36671,9 @@ ] } }, - "/api/w/{workspace_id}/tickets/{id}/state": { - "post": { - "operationId": "ticket_state_transition", + "/api/w/{workspace_id}/tickets/{id}/tags": { + "put": { + "operationId": "ticket_tags_set", "parameters": [ { "explode": false, @@ -35664,7 +36700,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/BrowserTransitionTicketStateRequest" + "$ref": "#/components/schemas/TicketTagSetRequest" } } }, @@ -35675,7 +36711,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TicketDetail" + "$ref": "#/components/schemas/Array_of_WorkspaceTag" } } }, @@ -35731,393 +36767,6 @@ }, "description": "Error response" }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/state-changes": { - "post": { - "operationId": "ticket_state_change_add", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketStateUpdate" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/state-fields/{field}": { - "post": { - "operationId": "ticket_state_field_set", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "field", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketStateUpdate" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/state-update": { - "post": { - "operationId": "ticket_state_update", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketStateUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Ticket" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, "500": { "content": { "application/json": { @@ -36249,246 +36898,6 @@ ] } }, - "/api/w/{workspace_id}/tickets/{id}/workflow-state": { - "post": { - "operationId": "ticket_workflow_state_set", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketStateUpdate" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/workflow/close": { - "post": { - "operationId": "ticket_close_record", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketCompletion" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, "/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready": { "post": { "operationId": "ticket_mark_ready_record", @@ -36713,6 +37122,347 @@ ] } }, + "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-answer": { + "post": { + "operationId": "ticket_assignment_answer_browser", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentAnswer" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentAnswerReceipt" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics": { + "get": { + "operationId": "ticket_assignment_diagnostics_browser", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentDiagnosticsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentDiagnosticsPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/api/w/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments": { + "get": { + "operationId": "ticket_assignments_page_browser", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentsPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/workers": { "get": { "operationId": "workspace_worker_list", @@ -38767,6 +39517,813 @@ } } } + }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-answer": { + "post": { + "operationId": "ticket_assignment_answer", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentAnswer" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentAnswerReceipt" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-diagnostics": { + "get": { + "operationId": "ticket_assignment_diagnostics", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentDiagnosticsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentDiagnosticsPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking": { + "get": { + "operationId": "ticket_assignment_tracking_get", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentTrackingQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Array_of_TicketAssignmentTrackingProjection" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + }, + "post": { + "operationId": "ticket_assignment_tracking_update", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentTrackingUpdate" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/assignments": { + "get": { + "operationId": "ticket_assignments_page", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentsQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentsPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking/history": { + "get": { + "operationId": "ticket_assignment_tracking_history", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": true, + "in": "query", + "name": "query", + "required": true, + "schema": { + "$ref": "#/components/schemas/TicketAssignmentTrackingHistoryQuery" + }, + "style": "form" + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketAssignmentTrackingHistoryPage" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/case": { + "post": { + "operationId": "ticket_case_update", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketCaseUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketSummary" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } } } } diff --git a/resources/flows/coder-review.dcdl b/resources/flows/coder-review.dcdl index 407f912a0..f09b93133 100644 --- a/resources/flows/coder-review.dcdl +++ b/resources/flows/coder-review.dcdl @@ -53,7 +53,7 @@ }; done = { - instructions = "The approved implementation has been handed off for Orchestrator-owned readiness, target integration, and `CompleteMergeRequest`. Flow terminal state only reflects that handoff."; + instructions = "The approved implementation has been handed off for Orchestrator-owned readiness, target integration, and `CompleteMergeRequest`. Flow terminal state only reflects that handoff; it is neither Ticket closed nor an assignment handling done self-report, work release, unassignment, or Worker stop. Generic Ticket closure has no universal MR gate; explicit user approval conditions and guarded MR integration remain separate."; terminal = true; }; }; diff --git a/resources/profiles/intake.dcdl b/resources/profiles/intake.dcdl index e38356424..dfe0a8a23 100644 --- a/resources/profiles/intake.dcdl +++ b/resources/profiles/intake.dcdl @@ -12,6 +12,6 @@ import "./base.dcdl" // { web = { enabled = true; }; sub_worker = { enabled = false; }; worker = { enabled = false; }; - ticket = { enabled = true; authoring = true; thread = true; intake = true; }; + ticket = { enabled = true; authoring = true; thread = true; }; }; } diff --git a/resources/profiles/orchestrator.dcdl b/resources/profiles/orchestrator.dcdl index 37870dcff..395451183 100644 --- a/resources/profiles/orchestrator.dcdl +++ b/resources/profiles/orchestrator.dcdl @@ -1,6 +1,6 @@ import "./base.dcdl" // { slug = "orchestrator"; - description = "Intent-led Ticket orchestration with separate guarded MR integration authority."; + description = "Intent-led Ticket delegation and self-reported assignment handling; case closure and guarded MR integration are separate."; scope = "workspace_write"; engine = { instruction = "role.orchestrator"; }; diff --git a/resources/prompts/common/merge-request.md b/resources/prompts/common/merge-request.md index 3ed7839ec..8fcf44b79 100644 --- a/resources/prompts/common/merge-request.md +++ b/resources/prompts/common/merge-request.md @@ -6,6 +6,6 @@ An open Merge Request keeps one immutable `selector_from` and `selector_to`. Adv A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket `content_digest` (SHA-256 of editable item content) captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Trusted Reviewer authority—not prose, Profile/Flow selection, or a tool mechanism alone—records the independent judgment. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. An instruction that review is not required for the Ticket does not bypass the guarded review requirements for MR integration. -`CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. `CompleteTicket` is an independent authorized decision with a reason and current item/state evidence, not an MR-set/approval gate. Judge the Ticket's requested result and return path separately: it may be completed or closed without MR integration, or returned without conclusion. Do not claim an unmerged result was integrated. +`CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. `TicketCaseUpdate` is an independent reasoned close/reopen/correction decision with current content/state/closure-key evidence, not an MR-set/approval gate. Judge the Ticket's requested result and return path separately: it may be completed or closed without MR integration, or returned without conclusion. Do not claim an unmerged result was integrated. Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a superseded workflow that appends source candidates. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or integration, stop and reread current authority rather than reusing stale evidence. diff --git a/resources/prompts/common/tickets.md b/resources/prompts/common/tickets.md index fd2f9663b..99854b6ee 100644 --- a/resources/prompts/common/tickets.md +++ b/resources/prompts/common/tickets.md @@ -8,6 +8,16 @@ Keep durable Ticket records centered on user intent, confirmed background, requi Choose a result and return path suited to the request: conversation, an optional Ticket comment, an optional Drive document/artifact when that surface is available, or a repository result. Results need not be persisted as comments or artifacts when the request does not require it. In an MR workflow, keep routine review requests, verdicts, fixes, and rereview evidence on the explicitly addressed MR rather than duplicating each iteration on the Ticket. Do not require an `implementation_report` or treat linked-MR evidence as universal Ticket completion authority. -Use authorized `TicketWorkflowState`, `CompleteTicket`, or `TicketClose` decisions with current item/state evidence and a reason when the request calls for them. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied. +Ticket cases have only `open` (still handled) and `closed` (handling ended for now); open is not execution readiness and closed is not necessarily fulfilled. Use authorized `TicketCaseUpdate` with `close`, `reopen`, or `correct_closure`, an explanation and typed closure reason when the request calls for it. Observe `content_digest`, case state, and nullable `closure_operation_key` from `ShowTicket` for CAS, and use a stable operation key for exact replay. Closure reasons and immutable history are separate from classification tags; tag changes never alter the closure judgment. Reread and reconcile after conflict; never guess a newer observation. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied. -Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker. +Classification tags use stable tag IDs, not display names, for mutations and filters. Use the shared Workspace tag tools for creation/list/rename/delete and Ticket tag-set changes with their observed operation identities; names such as `ready` never grant start permission. Tag-set CAS compares `expected_tag_ids` with the observed full ID set; tag rename/delete CAS compares `expected_last_operation_key` with the observed tag operation identity. Classification is separate from a close reason and never closes/reopens or delegates a Ticket. + +Read the current Orchestrator delegation with `TicketAssignmentTrackingQuery`; enumerate retained assignment IDs with bounded `TicketAssignmentsQuery`, then read a selected row and its `TicketAssignmentTrackingHistory`. Use `TicketAssignmentDiagnostics` for independent bounded delivery/input and start evidence; its separate cursors and explicit unknown/history fields are not a handling status. `QueryTicket` can filter the exact current `assigned_principal`, self-reported `handling_status`, and current closed-case `closure_reason`. Closure reason is distinct from tag IDs; retained/reopened reasons are history, and Worker status is never inferred. Each immutable `assignment_id` denotes this particular delegation to the stable Workspace Orchestrator principal, not a concrete Runtime Worker. Assign is delegation and initial judgment input; no ready step or separate Queue is required. `waiting`, `inprogress`, and `done` are the Orchestrator's self-reported handling, never Ticket case states, delivery/acceptance or Worker running/idle/stop. Record waiting reason, progress, next action and result through `TicketAssignmentTrackingUpdate` when authorized, not just conversation memory. Waiting must identify what is needed next; research returned to a human can be `open` + handling `done`. Respect an explicit user approval condition; do not add a universal human/MR gate. Done is not Backend certification of satisfaction, third-party approval, merge, actual work release, or Worker stop. + +Use `TicketOrchestratorAssign` for explicit initial delegation or reissue, with target principal, observed `expected_assignment_id` (explicit null only when unassigned), and stable `operation_id`. Query first; target principal is not the actor or an authority claim. The usual target is `{kind: "workspace_agent", agent_key: "workspace-orchestrator"}`; Backend validates role/principal and actual authentication. Use `TicketOrchestratorClear` only for explicit release of the observed assignment with its ID and operation_id, not automatically on done/closed. Neither operation starts or stops a Worker. Exact retry retains the same operation_id and full input; conflict or unknown outcome must not silently generate a new key. Reread current tracking before any subsequent mutation. + +Answers during waiting or ongoing handling continue the same assignment row; use `TicketAssignmentAnswer` with that `assignment_id`, observed `expected_last_event_id`, stable `operation_key`, and answer `body`, not an unfenced comment as a substitute. Do not reassign merely to deliver an answer or automatically declare inprogress on arrival. After done, an explicit reissue creates a new row/assignment_id even for the same principal and preserves the prior result. Reopen alone is neither reissue nor resurrection of an old Worker. Release/replacement remains separate from the old row's last status/result; done does not require unassignment. Worker replacement alone does not create a new delegation. Dependencies are diagnostics for judging partial work or waiting, not a blanket start gate; never start dependencies implicitly. + +For a new self-report, read the exact current assignment and send `assignment_id`, observed `last_event_id` as `expected_last_event_id`, a stable `operation_key`, and the complete tracking payload. Do not compare status alone or use a replay receipt as a fresh observation. Exact retries retain the same key and full input; reusing a key with changed input is rejected. After stale/replaced/cleared assignment or CAS conflict, reread and reconcile rather than silently overwrite, switch to a newer assignment, or retry as a new operation. Unknown outcomes are not success or failure: reconcile durable evidence and retry only the same operation when appropriate. Assign persistence, notification delivery, Runtime acceptance and actual work start are distinct outcomes; a delivery problem after saved Assign must not prompt another Assign. If a required typed operation or diagnostic endpoint is unavailable, report the concrete API insufficiency instead of inventing state, using a CLI/storage fallback, or creating a second ledger. + +Treat case state, assignment handling, and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker. diff --git a/resources/prompts/internal/compact_system.md b/resources/prompts/internal/compact_system.md index f0a4f53d6..08e43b0c6 100644 --- a/resources/prompts/internal/compact_system.md +++ b/resources/prompts/internal/compact_system.md @@ -1,10 +1,20 @@ You are a context compaction assistant. Your job is to hand the next session a structured summary plus pointers to the files it actually needs — not a narrative transcript of the conversation. -The conversation input is a bounded overview/index, not the full transcript. Treat tool result bodies and reasoning as intentionally omitted unless a tool exposes more detail. If you receive a compact worker budget warning, stop broad exploration immediately, read only if absolutely necessary, and call `write_summary`. +The conversation input contains a bounded overview/index, not the full transcript, and, when present, a separately labelled initial compaction summary in full. The initial summary is mandatory source material, not an overview preview or something to recover through exploration. Tool result bodies and reasoning are intentionally omitted; raw system prompts and reasoning are not exposed by session exploration. If you receive a compact worker budget warning, stop broad exploration immediately, read only if absolutely necessary to confirm load-bearing user wording, and call `write_summary` while preserving ongoing goals and directives. + +## Continuity policy + +- Treat ongoing goals and directives in the initial summary and this segment's user messages as the primary inheritance targets. Explore user entries to confirm important original wording when needed; do not reconstruct the request only from recent progress. +- Preserve scope, prohibitions, completion conditions, exceptions and duties to explain exceptional outcomes wherever possible. Prefer original wording for conditions whose loss would change the request's meaning. +- Interruptions, recovery, stash and SubWorker collection procedures do not by themselves retract or satisfy the original request. TaskStore, implementation and test progress do not replace user goals or completion conditions. +- Apply explicit changes or retractions in later user messages. The initial summary is not more authoritative than the original user text. Preserve uncertainty when conflict, completion or continued applicability cannot be established. +- When shrinking or near the budget limit, compress duplicate explanations and stale work details first. Do not replace ongoing goals and directives with recent procedures. Do not copy the whole transcript or accumulate expired directives unconditionally. +- The retained tail stays verbatim in the next session and is outside the summarized prefix. Preserve directives from the compacted prefix in the summary so they do not disappear from both summary and tail; integrate them with the continuing goals from the initial summary. + ## Workflow -1. Read the provided overview/index and current TaskStore snapshot. +1. Read the full initial compaction summary when supplied, the overview/index and current TaskStore snapshot. Keep user intent distinct from task/progress metadata. 2. If the provided index is not enough, call `ShowOverview` on the pinned capture. Use `SearchEntries` to locate relevant entries, then `ReadEntry` with the returned stable entry reference to inspect only what is needed. 3. Use `Read` to inspect referenced Workdir files before deciding what the next session needs. Prefer skimming over blind inclusion. 4. For UTF-8 text files whose current contents are load-bearing for the active work, call `mark_read_required` to inject them into the next session. These count against the auto-read token budget — spend it deliberately. Images and binary files cannot be injected; record them with `add_reference` so the next session can fetch them with `ViewImage` or the appropriate read tool. @@ -39,4 +49,4 @@ Produce the summary in this exact format: ## Constraints - Keep code snippets and raw tool output OUT of the summary — that is what auto-read and references are for. -- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version. +- Follow the summary target stated in the run input; if asked to shrink, call `write_summary` again with a shorter version using the continuity policy above, not by dropping ongoing user intent. diff --git a/resources/prompts/internal/workspace_orchestrator_queue_attention.md b/resources/prompts/internal/workspace_orchestrator_queue_attention.md index eee42a88e..87c914e12 100644 --- a/resources/prompts/internal/workspace_orchestrator_queue_attention.md +++ b/resources/prompts/internal/workspace_orchestrator_queue_attention.md @@ -1,8 +1,17 @@ -Queued Tickets require attention: +Assigned Tickets have unreceived input: {% for ticket in tickets -%} - {{ ticket.resource_key }} {{ separator }} {{ ticket.title }} +{% if ticket.assignment_id -%} + Delegation: {{ ticket.assignment_id }}; input: {{ ticket.input_event_id }}. +{% endif -%} +{% if ticket.waiting_reason -%} + Waiting reason: {{ ticket.waiting_reason }} +{% endif -%} +{% if ticket.next_action -%} + Next action: {{ ticket.next_action }} +{% endif -%} {% endfor -%} {% if omitted_ticket_count > 0 -%} -Additional queued Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set. +Additional assigned Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set. {% endif -%} -Reread the current Ticket state before acting. Preserve the human queue gate and current assignment, dependency, Worker, and Workdir authority; do not create duplicate work. +Reread the current Ticket state before acting, including current assignment tracking and new input. Assign is delegation; no ready/Queue step is required. Handling status is your self-report, not delivery or Worker state. Waiting alone and done history are not new work. Dependencies are diagnostics for partial work, not a blanket start gate; never implicitly start dependencies. Preserve current assignment identity, authenticated Worker, unfinished work, and Workdir/resource authority; do not create duplicate work. diff --git a/resources/prompts/panel/orchestrator_idle_queue_notice.md b/resources/prompts/panel/orchestrator_idle_queue_notice.md index eee42a88e..6ebc9c0a1 100644 --- a/resources/prompts/panel/orchestrator_idle_queue_notice.md +++ b/resources/prompts/panel/orchestrator_idle_queue_notice.md @@ -1,8 +1,8 @@ -Queued Tickets require attention: +Assigned Tickets have input requiring attention: {% for ticket in tickets -%} - {{ ticket.resource_key }} {{ separator }} {{ ticket.title }} {% endfor -%} {% if omitted_ticket_count > 0 -%} -Additional queued Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set. +Additional assigned Tickets were omitted from this notice: {{ omitted_ticket_count }}. Re-query current Ticket authority for the complete set. {% endif -%} -Reread the current Ticket state before acting. Preserve the human queue gate and current assignment, dependency, Worker, and Workdir authority; do not create duplicate work. +Reread the current Ticket, current assignment/event identity, handling and new input before acting. Assign is delegation; no ready/Queue step is required. Waiting alone or done history is not new work. Answers during waiting continue the same assignment; explicit reissue after done creates a new row. Supply the observed orchestrator_assignment_id for starts. Handling is a self-report, not delivery, acceptance or Worker state. Preserve authenticated Worker, unfinished work and Workdir/resource authority; dependencies are diagnostics, never implicitly started. Do not create duplicate work or infer delivery success from this notice. diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index 927d9fa9d..39dd4cc6e 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -1,6 +1,6 @@ -You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `CompleteTicket` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority. +You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `TicketCaseUpdate` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority. -Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. +Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. Ticket cases are open/closed; Orchestrator waiting/inprogress/done is its own assignment self-report, not your Worker state or MR evidence. Return bounded progress/results to the current delegation without claiming Orchestrator reporting authority. No ready/Queue step or mandatory terminal unassignment is part of code handoff. Use each existing repository-scoped Merge Request as the routine authority for its review requests, verdicts, fixes, and rereview cycles. A Ticket may have multiple Merge Requests, but at most one open Merge Request for a given repository. `OpenMergeRequest` creates that repository's selector-based Merge Request; if one already exists, use its explicit ID with `ShowMergeRequest`, keep its original selectors, and advance only that same source ref with a normal non-force push. Never invent an operation to append another source candidate, replacement Merge Request, or fresh integration branch. Source movement requires fresh review of the exact new ref. Target-only movement does not invalidate source approval and is handled later by Orchestrator integration authority. diff --git a/resources/prompts/role/intake.md b/resources/prompts/role/intake.md index d010cfc70..9ea776510 100644 --- a/resources/prompts/role/intake.md +++ b/resources/prompts/role/intake.md @@ -8,6 +8,6 @@ Use one Ticket for a deliverable that can be explained as a specification or fea Create or update Tickets only after user agreement or an explicit user instruction to record the agreed draft. Durable Ticket item/thread/resolution text should follow the configured worker language unless a Ticket-specific record language instruction is supplied by the host/environment. -Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to the user/Orchestrator queue flow. +Intake is not a scheduler. Do not spawn coder/reviewer/read-only investigation helper Workers, create implementation worktrees, route implementation/review, merge, close, or perform implementation side effects; leave those to explicit user/Orchestrator delegation. Ticket creation records an open case, not execution readiness. Classification tags use the shared stable tag identities and do not replace closure reasons or approval conditions. Assign starts Orchestrator tracking without a separate ready/Queue step; this role's authoring handoff is not itself Assign, Worker start, handling done, or Ticket closure. Do not infer requirements from a Ticket id or title alone; read the relevant Ticket record before updating it. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index 84b5fe42f..02b05d940 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -4,7 +4,9 @@ You are the Ticket Orchestrator role. Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Read the Ticket and follow its natural-language intent and requested return path; do not impose implementation, review, merge, approval, or conclusion on every Ticket. Work may be investigation, analysis, planning, documentation, or code. Results may be returned in conversation, optionally recorded in a Ticket comment or available Drive surface, or published to a repository as requested. Complete, close, continue, or return without conclusion according to the request and actual result, using only authorized typed tools. Tool mechanisms enforce authority, not real judgment of whether requirements are satisfied. -Explicitly start bounded Ticket work with `SpawnTicketWorker`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Recording `inprogress` is not a launch prerequisite: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope. +Read the current Ticket and `TicketAssignmentTrackingQuery` before interpreting an input or deciding work, waiting, or return. Preserve the same assignment for answers during ongoing/waiting handling; only an explicit reissue after done creates a new assignment row. Record your self-reported handling, waiting reason, progress, next action, and result with the observed assignment/event CAS and exact-retry key. Returning research can leave the Ticket open and handling done. User-requested approval conditions govern whether to wait; generic close has no universal MR or human approval gate. Do not infer status from delivery, acceptance, Worker state, or MR evidence, and do not always unassign at done or close. + +Explicitly start bounded Ticket work with `SpawnTicketWorker`, the observed current `orchestrator_assignment_id`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Case `open` is not a launch request: the explicit guarded operation accepts initial input, assignment, and attachment finalization without changing the case state. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. Supply that same observed delegation identity for generic `WorkerSpawn` Ticket starts and existing-Worker assignment as well; stale, cleared or reissued delegations cannot admit new work. Dependencies remain diagnostics, not a blanket refusal, and are never implicitly started. After start, reread the Ticket and verify the new Worker assignment identity and authoritative unfinished-work acceptance, not retained `current_worker` alone before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope. The following Git/MR practices apply to code work using that recipe, not to generic Ticket completion. A review-not-required request does not by itself authorize bypassing guarded MR integration. @@ -20,7 +22,7 @@ Push the resulting `merge_to` branch through its configured normal push path. Pr After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: accurately report remaining linked MRs and follow the requested continuation or return path; do not claim they were integrated. -If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized completion decision, reread `ShowTicket` and call `CompleteTicket` with `expected_content_digest` from current `content_digest`, expected state, a stable operation key, a reason, and optional supporting references. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends. +If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized case decision, reread `ShowTicket` and call `TicketCaseUpdate` with `expected_content_digest` from current `content_digest`, expected case state, observed nullable `closure_operation_key`, a stable operation key, the typed close/reopen/correction action, an explanation, and optional supporting references. Close with `completed` only if the request is actually fulfilled; other reasons close the case without claiming fulfillment. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. diff --git a/resources/prompts/role/ticket-worker.md b/resources/prompts/role/ticket-worker.md index cac0195f2..f90b9465b 100644 --- a/resources/prompts/role/ticket-worker.md +++ b/resources/prompts/role/ticket-worker.md @@ -1,7 +1,7 @@ You are the assigned Ticket Worker. Carry out the user's requested work and keep durable progress and completion evidence on the Ticket. Ticket work may be research, analysis, planning, documentation, or other non-code work; do not assume a repository, Workdir, Flow, git operation, Merge Request, or review cycle is required. -Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it. +Read the current Ticket with `ShowTicket` and its current assignment context before acting. Ticket open/closed, Orchestrator self-reported waiting/inprogress/done, actual Worker state/work release, and MR evidence are independent. Report bounded results or blockers to the Orchestrator for its same-delegation tracking; being the assigned Worker does not authorize you to self-report as the Workspace Orchestrator. An answer during waiting continues that assignment, whereas an explicit reissue after done creates a new row without overwriting prior results. No ready/Queue stage or terminal-always-unassign rule applies. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it. -Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the work meets its acceptance criteria and the request calls for completion, use `CompleteTicket` with a natural-language reason describing the result and optional supporting references. Use authorized `TicketClose` for requested closure when available. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction. +Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketCaseUpdate` only for a requested case judgment: reasoned close, reopen, or correction of the current closure. Choose `completed` only when actually fulfilled; `not_planned`, `duplicate`, `unknown`, or a documented custom reason do not claim fulfillment. The immutable history preserves prior actor, reason, explanation, and references. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected case state plus nullable `closure_operation_key` from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction. Completion evidence should describe what was done, how it was checked, and any remaining limitations. A commit, Merge Request, approval, or fixed workflow sequence is not a prerequisite for Ticket progress or completion. Do not claim work or validation that did not occur. Ticket completion is separate from Merge Request review or integration and does not confer either authority. If the request needs unavailable resources or authority, report the blocker and ask for the missing grant rather than assuming it. diff --git a/tools/state-contract-name-exceptions.json b/tools/state-contract-name-exceptions.json index d8d6714d4..e571712ea 100644 --- a/tools/state-contract-name-exceptions.json +++ b/tools/state-contract-name-exceptions.json @@ -1,15 +1,4 @@ [ - { - "path": "AGENTS.md", - "kind": "naming_policy", - "reason": "命名禁止の原語を明示する規則そのもの。消すと大小文字・複合語を含む禁止対象が曖昧になる。現行状態モデルの名称ではない。", - "lines": [ - { - "sha256": "b24c8ab3aabb18044f1011eda6cab552a47c735e6d6bc9ebf52df32a39eb6d2c", - "count": 1 - } - ] - }, { "path": "crates/manifest/src/lib.rs", "kind": "saved_data", @@ -559,9 +548,9 @@ ] }, { - "path": "crates/ticket/src/lib.rs", + "path": "crates/ticket/tests/migration.rs", "kind": "saved_data", - "reason": "frozen_counter_receipts_remain_immutable_and_cannot_be_reinterpreted_as_content_requestsの旧completion receipt属性fixture。保存済み旧fingerprintを新content requestの証拠に昇格せず、移行後も履歴を不変に保つ保証。keyを任意語に変えると実在したreceipt形式での回帰検出を失う。", + "reason": "旧Ticket完了receiptの実在したcounter入力keyを新schema7→9移行fixtureへ移設した。過去のfingerprintと全attributeを不変保持し、同じoperation keyを現行content/state/closure-key判断へ再解釈できないことを検証する。旧lib.rsの削除されたfixture例外を同じ1行へ移したもので、現行writer/APIには導入しない。", "lines": [ { "sha256": "060994ddcd0e463815b01cd11b19723fd3652bd4c741fccdb33628e97691f092", @@ -1235,7 +1224,7 @@ { "path": "crates/workspace-server/src/frozen_repository_key_migration.rs", "kind": "saved_data", - "reason": "schema 86から87の旧SSH key/secret/operation/retention/source列名。旧secret AADと暗号化payloadを開き新key identityへresealし、元operation fingerprint・retention・auditを対応付けるconsumerがある。削除すると既存秘密鍵を復号できず、SSH retry/retentionやauditを失う。曖昧な対応はrollbackし原文archiveを保持する。", + "reason": "schema 86から87の旧SSH key/secret/operation/retention/source列名。旧secret AADと暗号化payloadを開き新key identityへresealし、元operation fingerprint・retention・auditを対応付けるconsumerがある。削除すると既存秘密鍵を復号できず、SSH retry/retentionやauditを失う。曖昧な対応はrollbackし原文archiveを保持する。 時刻修正でhashが変わったcredential/host trust/候補の3 JOIN行は同じ旧保存列を読み、鍵作成UTC秒・ナノ秒を操作時刻と比較する。旧列を変えると実DBのSSH snapshotを対応付けられないため、この3行の例外だけを同期した。", "lines": [ { "sha256": "95284617f203ea22df2ba1fe49903db531a0dd68dbf8654981505516a07dfcce", @@ -1354,15 +1343,15 @@ "count": 1 }, { - "sha256": "8948263f582419232838423d99d269c423dee4b21c8189d97fde3de97afc2104", + "sha256": "a69d60581d924b868edf66ad6bfe63a47f44483aed13d7467df3303ad4b5d3c5", "count": 1 }, { - "sha256": "9b2d38cea48eaf100fdcd2444036da17a0a8104090b429e6c64e56255d45d9c0", + "sha256": "1397df3babbcbfdb0c3a7e12ce1a5bcaf396ef295880b4d2885bcd0b54fb6d1b", "count": 1 }, { - "sha256": "f8333763f67f3a40ae3e245f523e2b0dca6a7b6c1b42d5aa61273bcbf649c5f2", + "sha256": "291ea2e36bf20850c631eed6a64064c193c90d2fe2b346b0a13bc5031a1dbde9", "count": 1 }, { @@ -1404,14 +1393,14 @@ { "path": "crates/workspace-server/src/frozen_repository_key_tests.rs", "kind": "saved_data", - "reason": "legacy_repository_keysの旧SSH key/暗号secret/operation/retention/Workdir fixtureとrollback SQL。旧AADを本当に開いてresealし、元fingerprintでのreplay、retention、audit、不可逆な旧Workdirのarchiveを保持することを検証する。綴りを変えると既存secretの復号可能性と不整合rollbackの検証を失う。", + "reason": "legacy_repository_keysの旧SSH key/暗号secret/operation/retention/Workdir fixtureとrollback SQL。旧AADを本当に開いてresealし、元fingerprintでのreplay、retention、audit、不可逆な旧Workdirのarchiveを保持することを検証する。綴りを変えると既存secretの復号可能性と不整合rollbackの検証を失う。 既存fixtureの5 INSERT行は偽時刻からRFC3339へ変更されたためhashのみ同期した。旧credential/host trust表とcounterの綴り・実AAD入力は不変であり、移行成功と別鍵retention/欠けたreceiptのrollbackを同じ旧形式で検証する。", "lines": [ { "sha256": "a62f153e1748caa20f7cb81656b8c6b29d32b01bf533a3ffaf36fb5be9a5ae3b", "count": 1 }, { - "sha256": "de6b3a38afad3d08bc4bcb534c1374446ae59b28ed08235643fd61c3b42992fd", + "sha256": "59be6fd4e7c21afa91e2618bde39dbf7b2325be929723cabc19d75eebc0fe606", "count": 1 }, { @@ -1427,11 +1416,11 @@ "count": 1 }, { - "sha256": "af41b9c5edf66189130020a79568cad6b86b56835935e06969d34a4cc0569e93", + "sha256": "d7a003785fdc9de68e8f980f9ccc49360b5ecf9c382a9d34b348d3070cca724c", "count": 1 }, { - "sha256": "9ec9ccf2b8e304c1cb1ef9b755a5cfe4d203879a9787394a9c6edca59c6fddca", + "sha256": "3ce073da6367a945e80717f01b342a6ec3eb6495fce1f8bf651ce9406584d8b3", "count": 1 }, { @@ -1475,7 +1464,7 @@ "count": 1 }, { - "sha256": "b2495afb6d5b32f13e77eb7a7fa4866f12531d3ed059aa44288ccb69d0fa4e26", + "sha256": "04ea12161ed1f19d3edd90d0619d03e80a9aaa5c9854b46e437e3c87852ec0ff", "count": 1 }, { @@ -1483,7 +1472,7 @@ "count": 1 }, { - "sha256": "4a4ea05d99ba512fbe0ecb7c31d176a08c59bb6e8b1156624bee907ad4dfc103", + "sha256": "906649318474b0ef47fa35be64c7f9b7f6fe1e50aa477d6bb424137afe079fb2", "count": 1 }, { @@ -1905,7 +1894,7 @@ { "path": "crates/workspace-server/src/migration_entrypoint_tests.rs", "kind": "saved_data", - "reason": "schema-87 retention/removal policyとschema-86 SSH key fixture。public migrate_database/open entrypointが旧DBのpolicy binding・terminal worker evidence・暗号鍵を移すことを検証する。旧SQLを現行名へ変えると公開入口を通る実データ保持/rollbackの証明を失う。", + "reason": "schema-87 retention/removal policyとschema-86 SSH key fixture。public migrate_database/open entrypointが旧DBのpolicy binding・terminal worker evidence・暗号鍵を移すことを検証する。旧SQLを現行名へ変えると公開入口を通る実データ保持/rollbackの証明を失う。 公開dry-run/applyのcredential保存行は偽時刻からRFC3339へ変更されたためhashのみ同期した。元暗号AADとkey receiptに結び付いた旧表名を維持し、旧DBを公開入口から移す証明を残す。", "lines": [ { "sha256": "2a4cb9f8dacaf637103fe369c8ce5106bbb442097e644483c3900cdd98fade81", @@ -1924,7 +1913,7 @@ "count": 1 }, { - "sha256": "de6b3a38afad3d08bc4bcb534c1374446ae59b28ed08235643fd61c3b42992fd", + "sha256": "59be6fd4e7c21afa91e2618bde39dbf7b2325be929723cabc19d75eebc0fe606", "count": 1 } ] @@ -2158,5 +2147,86 @@ "count": 1 } ] + }, + { + "path": "crates/workspace-server/src/frozen_repository_key_migration.rs", + "kind": "saved_data", + "reason": "prepare_repository_cutover_timesの旧schema86 snapshot照合SQL3行(credential operation、host trust operation、credential candidate)。それぞれ実在した旧保存counter列を取り出し、鍵作成時刻が操作時刻より新しい再利用鍵や証拠欠落をコピー前に拒否する。綴りを現行列へ置換すると旧DBを読めず、NULL fingerprint挿入前の安全なrollbackとretained SSH retry証拠の保持を失う。新writerの状態名として使わない。", + "lines": [ + { + "sha256": "fdd4d8fa9046d50591afa726d7d12c455020571a5b74f1e6c748556b36f50db6", + "count": 1 + }, + { + "sha256": "765772579ddb0b8e014bed913836f722a25f97a4293abcc823e2d23ab7d0b3b4", + "count": 1 + }, + { + "sha256": "c368d019ed0698f769513bf2da22f2f8894f66ebfa4242554544d5ca05316773", + "count": 1 + } + ] + }, + { + "path": "crates/workspace-server/src/frozen_repository_key_tests.rs", + "kind": "saved_data", + "reason": "legacy_ssh_snapshot_timesとnewer_reused_snapshot_keys/invalid_authority_timestampsの旧保存入力。credential時刻UPDATE3箇所とhost trust時刻UPDATE2箇所は混在形式の成功・操作より新しい鍵の拒否・不正時刻のrollbackを分けて検証する。host INSERTとfailed Workdir snapshot UPDATEは実旧receiptを作り、main credentialをNULLにする1行は候補だけの不整合を隔離する。credential/host trustのsource文字列とmatch各2行は不正値を実旧表のcreated_atへ入れて文脈付き拒否を確認する。旧列/表の綴りを変えるとfixtureを操作できず、過去のSSH snapshotに別鍵を結び付けない検証を失う。", + "lines": [ + { + "sha256": "f49461fbbb42bf1a13996379f031645d31ab7f794e64c6cc66fe6a5e5d14ecf8", + "count": 3 + }, + { + "sha256": "651e611e62c3be58f95b2c5aae3f95f98ea3858180b77132968bef7ae4616165", + "count": 1 + }, + { + "sha256": "f78c4397f9e4c8058fa8d320949e0cc23ef8c47c37a643cf73e91cb84c4d4b38", + "count": 1 + }, + { + "sha256": "0c49320a2fcff35a9fffd8eb02517c97e538006c614b6243d02757fd12c23727", + "count": 2 + }, + { + "sha256": "4e64ea62da159a158270bb0e6f9c1086090a73deb826f4b8df02438f8065fd2d", + "count": 1 + }, + { + "sha256": "48ffab07c0e4a66b82944f7001e6cc8ffef8a964f01a1b348db14537204f6a7f", + "count": 1 + }, + { + "sha256": "18341cc32d7711507e0ec849ba9291c7265944149d9b7814c72a6310272546f4", + "count": 1 + }, + { + "sha256": "739474845828016af6993686f70b3d716fbe2f37cba59aa0f2e9a3659e40ce2c", + "count": 1 + }, + { + "sha256": "cc05720883982d4345c5059aebbfc6f2dfc4d07126d2ee0425acde502c4a762b", + "count": 1 + } + ] + }, + { + "path": "crates/workspace-server/src/frozen_repository_key_tests.rs", + "kind": "saved_data", + "reason": "assert_timestamp_cutover_rolls_backの旧credential表存在と旧retention件数の2行、およびrepository_key_cutover_rejects_orphan_candidates_without_dropping_source_evidenceの旧表存在1行。失敗後にもschema86の秘密鍵・retained retry参照・候補が消えないことを実旧表名で確認する保存データ保持のassertionであり、旧名を現行出力に許可する契約ではない。削除するとtimestamp/candidate拒否時の部分移行やデータ喪失を検出できなくなる。", + "lines": [ + { + "sha256": "d7080c861537f04caf0ddddc36c166eb965b74bc8dbd57f5d4a3ef8232804c66", + "count": 1 + }, + { + "sha256": "821421c002e9020248029b8a74b6b86ab3abd9c37d719cd3bf2572b096f54e9f", + "count": 1 + }, + { + "sha256": "1378598b848b6e444d643deb72f7403281d548885557ba819822d838a8148d81", + "count": 1 + } + ] } ] diff --git a/tools/web-ux/browser-tests/ticket_fixture_server.ts b/tools/web-ux/browser-tests/ticket_fixture_server.ts index e028071df..ea0822683 100644 --- a/tools/web-ux/browser-tests/ticket_fixture_server.ts +++ b/tools/web-ux/browser-tests/ticket_fixture_server.ts @@ -5,22 +5,133 @@ import { fixtureDetail, fixturePage, } from "../../../web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts"; -import type { TicketDetail } from "../../../web/workspace/src/lib/generated/ticket-api.ts"; +import type { + TicketAssignmentTracking, + TicketAssignmentTrackingHistoryEntry, + TicketAssignmentTrackingProjection, + TicketDetail, + TicketQueryItem, + TicketQueryRequest, + WorkspaceTag, +} from "../../../web/workspace/src/lib/generated/ticket-api.ts"; import { emptyLaunchOptions } from "../../../web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts"; const port = Number(Deno.args[0]); const root = Deno.args[1]; const legacy = Deno.args.includes("--legacy"); const tickets = new Map<string, TicketDetail>(); const bodies: unknown[] = []; +const retained = new Map<string, TicketAssignmentTrackingProjection>(); +const handlingHistory = new Map< + string, + TicketAssignmentTrackingHistoryEntry[] +>(); +const receipts = new Map<string, { fingerprint: string; result: unknown }>(); +const tags: WorkspaceTag[] = [ + { + id: "tag-investigation", + name: "Investigation", + workspace_id: "tickets", + deleted: false, + last_operation_key: "create-investigation", + }, + { + id: "tag-ui", + name: "UI", + workspace_id: "tickets", + deleted: false, + last_operation_key: "create-ui", + }, +]; +let assignmentNumber = 10; +let failure: "none" | "unknown_saved" | "unknown_unsaved" | "stale" | "denied" = + "none"; +function remember(row: TicketAssignmentTrackingProjection) { + retained.set(row.assignment_id, row); + if (!handlingHistory.has(row.assignment_id)) { + handlingHistory.set( + row.assignment_id, + row.tracking + ? [{ + at: row.updated_at ?? row.assigned_at, + event_id: row.last_event_id!, + operation_key: `initial-${row.assignment_id}`, + source_context: { actor: "fixture-orchestrator" }, + tracking: structuredClone(row.tracking), + }] + : [], + ); + } +} +function queryItem(t: TicketDetail): TicketQueryItem { + return { + id: t.id, + resource_key: t.resource_key, + title: t.title, + state: t.state, + priority: t.priority, + tags: t.tags, + assignment_tracking: t.assignment_tracking, + closure_operation_key: t.closure_operation_key, + current_closure: t.current_closure, + current_worker: t.current_worker, + content_digest: t.content_digest, + created_at: t.created_at, + updated_at: t.updated_at, + legacy_handoff: t.legacy_handoff, + readiness: t.readiness, + workspace_action_priority: "background", + evidence: t.evidence, + merge_request: t.merge_request, + merge_requests: t.merge_requests, + linked_objective_ids: [], + linked_objective_keys: [], + matched_fields: [], + snippet: t.body, + matching_event: null, + relation_count: t.relations.outgoing.length, + blocker_count: t.relations.blockers.length, + unresolved_blocker_count: t.relations.blockers.length, + unresolved_review_count: 0, + }; +} +function pageSlice<T>(items: T[], cursor: string | null, limit: number) { + const offset = Number(cursor ?? 0); + const slice = items.slice(offset, offset + limit); + const hasMore = offset + slice.length < items.length; + return { + items: slice, + page: { + ...fixturePage(slice.length), + limit, + sort: "updated_desc", + has_more: hasMore, + next_cursor: hasMore ? String(offset + slice.length) : null, + }, + }; +} function contentDigest(value: TicketDetail): string { - return createHash("sha256").update("ticket.content.sha256.v1\0").update(JSON.stringify([ - value.id, value.title, value.body, value.targets.map(({ repository_key, ref_selector, access }) => ({ repository_key, ref_selector, access })), - ])).digest("hex"); + return createHash("sha256").update("ticket.content.sha256.v1\0").update( + JSON.stringify([ + value.id, + value.title, + value.body, + value.targets.map(({ repository_key, ref_selector, access }) => ({ + repository_key, + ref_selector, + access, + })), + ]), + ).digest("hex"); } function ticket(key: string) { if (!tickets.has(key)) { - const value: TicketDetail = fixtureDetail(key); - value.state = "planning"; + const value: TicketDetail = { + ...fixtureDetail(key), + assignment_tracking: [], + }; + value.state = "open"; + value.created_at = "2025-12-31T00:00:00Z"; + value.updated_at = "2026-01-02T00:00:00Z"; value.title = key === "T-2" ? "Read-only investigation" : key === "T-3" @@ -31,11 +142,117 @@ function ticket(key: string) { : key === "T-3" ? "Implement the change, then independent review before merge." : "Return options to the user. Do not draw a conclusion."; - value.action_eligibility.can_queue = true; - value.action_eligibility.queue_tickets = [key]; - value.action_eligibility.can_start_manual_worker = true; + value.action_eligibility.can_assign_orchestrator = true; + value.action_eligibility.can_unassign_orchestrator = true; + value.action_eligibility.can_queue = false; + value.action_eligibility.queue_tickets = []; + const tracking: TicketDetail["assignment_tracking"][number] = { + workspace_id: "tickets", + ticket_id: value.id, + assignment_id: `assignment-${key}`, + role: "orchestrator", + principal: { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, + assigned_at: "2026-01-01T00:00:00Z", + assigned_by: "fixture-owner", + current: true, + last_event_id: `handling-${key}`, + updated_at: "2026-01-02T00:00:00Z", + migration_diagnostic: null, + release: null, + tracking: key === "T-2" + ? { + status: "waiting", + waiting_reason: "Awaiting the user's policy choice", + next_action: "Continue after the answer", + progress: null, + result: null, + } + : key === "T-3" + ? { + status: "inprogress", + progress: "Preparing independent review", + next_action: "Request review of the exact source", + waiting_reason: null, + result: null, + } + : { + status: "done", + result: "Compared options; returned them without deciding", + waiting_reason: null, + progress: null, + next_action: null, + }, + }; + value.assignment_tracking = [tracking]; + value.assignments = [{ + assignment_id: tracking.assignment_id, + role: tracking.role, + principal: tracking.principal, + assigned_at: tracking.assigned_at, + assigned_by: tracking.assigned_by, + }]; + value.tags = [structuredClone(tags[0])]; + if (key === "T-3" || key === "T-35") { + value.tags.push(structuredClone(tags[1])); + } + if (key === "T-3") { + value.current_worker = { + assignment_id: "worker-assignment-3", + runtime_id: "embedded", + worker_id: "running-worker", + worker_resource_key: "W-3", + }; + } if (key === "T-2") { - value.targets = [{ repository_key: "main", ref_selector: "develop", access: "read_only" }]; + value.current_worker = { + assignment_id: "worker-assignment-2", + runtime_id: "embedded", + worker_id: "stopped-worker", + worker_resource_key: "W-2", + }; + } + if (key === "T-4") { + value.title = "Completed investigation with retained history"; + value.body = + "Investigate and deliver the complete findings, then judge case closure separately."; + tracking.tracking = { + status: "done", + result: "Delivered the full requested investigation", + waiting_reason: null, + progress: null, + next_action: null, + }; + value.state = "closed"; + value.closure_operation_key = "close-T-4"; + value.current_closure = { + at: "2026-01-02T00:00:00Z", + author: "fixture-owner", + reason: "completed", + explanation: "The requested investigation was delivered", + operation_key: "close-T-4", + references: [], + }; + value.closure_history = [{ + at: value.current_closure.at, + author: value.current_closure.author, + explanation: value.current_closure.explanation, + operation_key: "close-T-4", + references: [], + action: { action: "close", reason: "completed" }, + previous_closure_operation_key: null, + }]; + value.action_eligibility.can_start_manual_worker = false; + } + value.action_eligibility.can_start_manual_worker = value.state === "open"; + if (key === "T-2") { + value.targets = [{ + repository_key: "main", + ref_selector: "develop", + access: "read_only", + }]; } if (key === "T-3") { value.merge_requests = [{ @@ -45,7 +262,10 @@ function ticket(key: string) { selector_from: "work/fixture", selector_to: "develop", review_status: "none", - source_ref_observation: { status: "unavailable", code: "provider_unavailable" }, + source_ref_observation: { + status: "unavailable", + code: "provider_unavailable", + }, current_subject_ref: null, review_subject_ref: null, review_excerpt: null, @@ -53,6 +273,38 @@ function ticket(key: string) { updated_at: "2026-01-02T00:00:00Z", }]; } + remember(tracking); + if (["T-1", "T-4"].includes(key)) { + const previous: TicketAssignmentTrackingProjection = { + ...structuredClone(tracking), + assignment_id: `previous-${key}`, + current: false, + last_event_id: `previous-event-${key}`, + release: { + action: "replaced", + actor: "fixture-owner", + at: "2026-01-01T00:00:00Z", + event_id: `replace-${key}`, + reason: "New request", + replacement_assignment_id: tracking.assignment_id, + last_tracking: structuredClone(tracking.tracking), + operation_key: `replace-op-${key}`, + }, + }; + remember(previous); + if (key === "T-4") { + remember({ + ...structuredClone(previous), + assignment_id: "released-T-4", + release: { + ...previous.release!, + action: "unassigned", + replacement_assignment_id: null, + reason: "Responsibility released without stopping a Worker", + }, + }); + } + } tickets.set(key, value); } const stored = tickets.get(key)!; @@ -68,10 +320,110 @@ function ticket(key: string) { } return value; } +function projectedAssignment( + row: TicketAssignmentTrackingProjection, + workspaceId: string, +) { + const value = { ...structuredClone(row), workspace_id: workspaceId }; + if (workspaceId === "tickets-unknown") { + value.tracking = null; + value.updated_at = null; + value.last_event_id = `legacy-assign-${value.assignment_id}`; + value.migration_diagnostic = + "Historical delegation has unknown handling; no status inferred"; + if (value.release) value.release.last_tracking = null; + } + return value; +} +function projectedTicket(key: string, workspaceId: string) { + const value = ticket(key); + value.tags = value.tags.map((tag) => + tags.find((canonical) => canonical.id === tag.id) ?? tag + ) + .filter((tag) => !tag.deleted).map((tag) => ({ + ...tag, + workspace_id: workspaceId, + })); + if (workspaceId === "tickets-readonly") { + value.action_eligibility = { + blockers: ["Read-only fixture"], + can_assign_orchestrator: false, + can_unassign_orchestrator: false, + can_start_manual_worker: false, + can_queue: false, + queue_tickets: [], + }; + } + value.assignment_tracking = value.assignment_tracking.map((row) => + projectedAssignment(row, workspaceId) + ); + if (workspaceId === "tickets-long") { + value.title += + " — Long investigation title with a full comparison and explicit user confirmation " + .repeat(4); + for (const row of value.assignment_tracking) { + if (row.tracking) { + row.tracking.waiting_reason = + "Waiting for a deliberate user choice, not a Worker state change. " + .repeat(8); + row.tracking.result = + "Returned comparison: no Ticket closure, approval or Worker stop inferred.\n" + .repeat(8); + } + } + } + return value; +} +function workerSummary(workerId: string, state: string) { + return { + worker_id: workerId, + runtime_id: "embedded", + host_id: "fixture-host", + display_name: workerId, + label: workerId, + profile: "builtin:orchestrator", + singleton_key: workerId === "orchestrator-worker" + ? "workspace-orchestrator" + : null, + tags: [], + workspace: { + visibility: "shared", + identity: "tickets", + workspace_id: "tickets", + }, + state, + last_seen_at: "2026-01-02T00:00:00Z", + pinned: false, + retention_state: "retained", + implementation: { kind: "yoi", display_hint: "Fixture Worker" }, + workdir_attachments: [], + diagnostics: [], + }; +} Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const url = new URL(request.url); if (url.pathname === "/health") return new Response("ok"); if (url.pathname === "/fixture-state") return Response.json(bodies); + if (url.pathname === "/fixture-snapshot") { + return Response.json({ + tickets: [...tickets.values()], + assignments: [...retained.values()], + receipts: [...receipts.keys()], + }); + } + if (url.pathname === "/fixture-control" && request.method === "POST") { + const control = await request.json(); + if (control.reset) { + tickets.clear(); + retained.clear(); + handlingHistory.clear(); + receipts.clear(); + bodies.length = 0; + failure = "none"; + } + if (control.failure) failure = control.failure; + return Response.json({ configured: true }); + } if (url.pathname === "/api/workspaces") { return Response.json([{ workspace_id: "tickets", @@ -82,7 +434,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { updated_at: "2026-01-02T00:00:00Z", }]); } - const match = url.pathname.match(/^\/api\/w\/([^/]+)(\/.*)$/); + const match = url.pathname.match( + /^(?:\/api\/w|\/workspaces)\/([^/]+)(\/.*)$/, + ); if (match) { const [, id, path] = match; if (path === "/workspace") { @@ -108,8 +462,16 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { extension_points: { store: "fixture", event_stream: { status: "ready", note: "fixture", diagnostics: [] }, - host_worker_bridge: { status: "ready", note: "fixture", diagnostics: [] }, - companion_console: { status: "ready", note: "fixture", diagnostics: [] }, + host_worker_bridge: { + status: "ready", + note: "fixture", + diagnostics: [], + }, + companion_console: { + status: "ready", + note: "fixture", + diagnostics: [], + }, }, }); } @@ -133,9 +495,12 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (path === "/orchestrator") { return Response.json({ workspace_id: id, - online: false, - disposition: "offline", - worker: null, + online: id === "tickets-running", + disposition: id === "tickets-running" ? "online" : "offline", + worker: workerSummary( + "orchestrator-worker", + id === "tickets-running" ? "Running" : "Stopped", + ), diagnostics: [], }); } @@ -173,6 +538,17 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { } return Response.json(options); } + if (path === "/workers/W-2" || path === "/workers/W-3") { + const running = path === "/workers/W-3"; + return Response.json({ + ...workerSummary( + running ? "running-worker" : "stopped-worker", + running ? "Running" : "Stopped", + ), + resource_key: running ? "W-3" : "W-2", + availability: "observed", + }); + } if (path === "/workers") { return Response.json({ workspace_id: id, @@ -185,7 +561,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { if (path === "/working-directories") { return Response.json({ workspace_id: id, items: [], diagnostics: [] }); } - if (path === "/protocol/ws" && request.headers.get("upgrade") === "websocket") { + if ( + path === "/protocol/ws" && request.headers.get("upgrade") === "websocket" + ) { const { socket, response } = Deno.upgradeWebSocket(request); socket.onmessage = (event) => { const frame = JSON.parse(String(event.data)); @@ -207,23 +585,141 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { }; return response; } + if (path === "/tickets/query" && request.method !== "POST") { + return Response.json( + { message: "Use POST with TicketQueryRequest JSON" }, + { status: 405 }, + ); + } + if (path === "/tickets/query" && request.method === "POST") { + const body: TicketQueryRequest = await request.json(); + bodies.push({ path, method: request.method, body }); + if (id === "tickets-error") { + return Response.json({ + message: + "Ticket query unavailable — recover by retrying the same read", + }, { status: 503 }); + } + const keys = id === "tickets-empty" + ? [] + : id === "tickets-pages" + ? Array.from({ length: 40 }, (_, index) => `T-${index + 1}`) + : ["T-1", "T-2", "T-3", "T-4"]; + if (id !== "tickets-empty" && tickets.has("T-41")) keys.push("T-41"); + const items = keys.map((key) => projectedTicket(key, id)).filter((t) => { + if ( + body.states?.length && + !body.states.includes(t.state as "open" | "closed") + ) return false; + if ( + body.closure_reason && + (t.state !== "closed" || + JSON.stringify(t.current_closure?.reason) !== + JSON.stringify(body.closure_reason)) + ) return false; + const reasons = t.closure_history.map((entry) => + entry.action.action === "reopen" + ? "" + : typeof entry.action.reason === "string" + ? entry.action.reason + : entry.action.reason.other + ).join(" "); + const query = body.query?.toLocaleLowerCase(); + if ( + query && + !`${t.resource_key} ${t.title} ${t.body} ${ + t.current_closure?.explanation ?? "" + } ${reasons}` + .toLocaleLowerCase().includes(query) + ) return false; + if ( + body.tag_ids?.some((tagId) => !t.tags.some((tag) => tag.id === tagId)) + ) return false; + const rows = t.assignment_tracking.filter((row) => + row.current && + (!body.assigned_principal || + JSON.stringify(row.principal) === + JSON.stringify(body.assigned_principal)) + ); + if (body.assigned_principal && !rows.length) return false; + if ( + body.handling_status && + !rows.some((row) => row.tracking?.status === body.handling_status) + ) return false; + return true; + }).map(queryItem); + return Response.json({ + ...pageSlice(items, body.cursor ?? null, body.limit ?? 30), + record_authority: "fixture", + }); + } + if (path === "/ticket-tags") { + if (request.method === "GET") { + return Response.json(tags.map((tag) => ({ ...tag, workspace_id: id }))); + } + if (id === "tickets-readonly") { + return Response.json({ message: "Permission denied" }, { status: 403 }); + } + const body = await request.json(); + bodies.push({ path, method: request.method, body }); + const existing = tags.find((tag) => tag.id === body.action.tag_id); + if (body.action.kind === "create") { + const tag = { + id: `tag-${tags.length + 1}`, + name: body.action.name, + workspace_id: id, + last_operation_key: body.operation_key, + deleted: false, + }; + tags.push(tag); + return Response.json(tag); + } + if (!existing) { + return Response.json({ message: "Tag unavailable" }, { status: 404 }); + } + if ( + body.action.expected_last_operation_key !== existing.last_operation_key + ) { + return Response.json({ message: "Tag changed; refresh" }, { + status: 409, + }); + } + if (body.action.kind === "rename") existing.name = body.action.name; + if (body.action.kind === "delete") { + existing.deleted = true; + for (const value of tickets.values()) { + value.tags = value.tags.filter((tag) => tag.id !== existing.id); + } + } + existing.last_operation_key = body.operation_key; + return Response.json(existing); + } if (path === "/tickets" && request.method === "POST") { + if (id === "tickets-readonly") { + return Response.json({ + message: "Permission denied; read-only fixture", + }, { status: 403 }); + } const body = await request.json(); bodies.push({ path, body }); - ticket("T-4"); - const created = tickets.get("T-4")!; + ticket("T-41"); + const created = tickets.get("T-41")!; + created.assignment_tracking = []; + created.assignments = []; + created.tags = []; + created.action_eligibility.can_unassign_orchestrator = false; created.title = body.title; created.body = body.body; created.targets = body.targets ?? []; return Response.json({ id: created.id, - resource_key: "T-4", + resource_key: "T-41", slug: "fixture", status: "Open", }); } if (path === "/tickets") { - const items = ["T-1", "T-2", "T-3"].map(ticket).filter((t) => + const items = ["T-1", "T-2", "T-3", "T-4"].map(ticket).filter((t) => !url.searchParams.has("states") || url.searchParams.get("states")!.split(",").includes(t.state) ); @@ -234,6 +730,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { resource_key: t.resource_key, title: t.title, state: t.state, + tags: t.tags, + closure_operation_key: t.closure_operation_key, + current_closure: t.current_closure, priority: t.priority, updated_at: t.updated_at, queued_at: t.queued_at, @@ -250,35 +749,403 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { const tm = path.match(/^\/tickets\/([^/]+)(.*)$/); if (tm) { const key = decodeURIComponent(tm[1]).match(/T-\d+/)?.[0] ?? "T-1"; + const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!); + const suffix = tm[2]; + if ( + request.method === "GET" && + suffix === "/assignment-tracking/assignments" + ) { + const rows = [...retained.values()].filter((row) => + row.ticket_id === stored.id + ); + const after = url.searchParams.get("after_assignment_id"); + const offset = after + ? rows.findIndex((row) => row.assignment_id === after) + 1 + : 0; + const items = rows.slice( + offset, + offset + Number(url.searchParams.get("limit") ?? 20), + ); + return Response.json({ + items: items.map((row) => projectedAssignment(row, id)), + next_assignment_id: offset + items.length < rows.length + ? items.at(-1)?.assignment_id + : null, + }); + } + if (request.method === "GET" && suffix === "/assignment-diagnostics") { + const assignmentId = url.searchParams.get("assignment_id"); + const row = retained.get(assignmentId ?? ""); + if (!row || row.ticket_id !== stored.id) { + return Response.json({ message: "Assignment unavailable" }, { + status: 404, + }); + } + return Response.json({ + assignment_id: row.assignment_id, + current: row.current, + input_origin_known: id !== "tickets-unknown", + actionable: id !== "tickets-unknown" && row.current && + stored.state === "open" && + row.tracking?.status !== "done", + inputs: id === "tickets-unknown" ? [] : [ + { + input_event_id: `assign-input-${row.assignment_id}`, + delivery_id: key === "T-2" + ? `delivery-input-${row.assignment_id}` + : null, + notification: key === "T-2" + ? "request_persisted" + : "not_reserved", + acceptance_outcome_unknown: key === "T-2", + }, + ...stored.events.filter((entry) => + entry.kind === "assignment_answer" && + entry.attributes.orchestrator_assignment_id === row.assignment_id + ).map((entry) => ({ + input_event_id: entry.event_ref, + delivery_id: null, + notification: "not_reserved", + acceptance_outcome_unknown: false, + })), + ], + next_input_event_id: null, + starts: key === "T-3" && id !== "tickets-unknown" + ? [{ + operation_id: "start-T-3", + claim_state: "committed", + assignment_id: "worker-assignment-3", + runtime_id: "embedded", + worker_id: "running-worker", + actual_processing_unknown: true, + }] + : [], + next_start_operation_id: null, + }); + } + if ( + request.method === "GET" && suffix.startsWith("/assignment-tracking") + ) { + const assignmentId = url.searchParams.get("assignment_id"); + if (suffix === "/assignment-tracking/history") { + const rows = id === "tickets-unknown" + ? [] + : handlingHistory.get(assignmentId ?? "") ?? []; + const after = url.searchParams.get("after_event_id"); + const offset = after + ? rows.findIndex((entry) => entry.event_id === after) + 1 + : 0; + const items = rows.slice( + offset, + offset + Number(url.searchParams.get("limit") ?? 20), + ); + return Response.json({ + items, + next_event_id: offset + items.length < rows.length + ? items.at(-1)?.event_id + : null, + }); + } + return Response.json( + assignmentId + ? [...retained.values()].filter((row) => + row.ticket_id === stored.id && row.assignment_id === assignmentId + ).map((row) => projectedAssignment(row, id)) + : projectedTicket(key, id).assignment_tracking, + ); + } + if ( + request.method !== "GET" && + ["/state", "/close", "/ready", "/queue"].includes(suffix) + ) { + return Response.json({ + message: + "Legacy phase actions are unavailable; use a reasoned case judgment or Assign", + }, { status: 404 }); + } + if (request.method !== "GET" && id === "tickets-readonly") { + return Response.json({ + message: "Permission denied; read-only fixture", + }, { status: 403 }); + } + if ( + request.method !== "GET" && + [ + "/assignments/orchestrator", + "/assignment-answer", + "/assignment-tracking", + "/case", + "/tags", + ].includes(suffix) + ) { + if (id === "tickets-readonly") { + return Response.json({ + message: "Permission denied; read-only fixture", + }, { status: 403 }); + } + const body = request.method === "DELETE" + ? Object.fromEntries(url.searchParams) + : await request.json(); + bodies.push({ path, method: request.method, body }); + const operationKey = body.operation_id ?? body.operation_key; + if (!operationKey) { + return Response.json({ message: "Operation identity required" }, { + status: 400, + }); + } + const fingerprint = JSON.stringify({ + path, + method: request.method, + body, + }); + const receipt = receipts.get(operationKey); + if (receipt) { + return receipt.fingerprint === fingerprint + ? Response.json(receipt.result) + : Response.json({ message: "Operation identity conflict" }, { + status: 409, + }); + } + const injected = failure; + failure = "none"; + if (injected === "stale") { + return Response.json({ + message: "Assignment changed; refresh before a new operation", + }, { status: 409 }); + } + if (injected === "denied") { + return Response.json({ message: "Permission denied" }, { + status: 403, + }); + } + if (injected === "unknown_unsaved") { + return Response.json({ message: "Outcome unknown" }, { status: 503 }); + } + const current = stored.assignment_tracking.find((row) => + row.current && row.role === "orchestrator" + ); + let result: unknown; + if (suffix === "/assignments/orchestrator") { + const expected = request.method === "DELETE" + ? body.assignment_id + : body.expected_assignment_id; + if (expected !== (current?.assignment_id ?? null)) { + return Response.json({ message: "Stale assignment identity" }, { + status: 409, + }); + } + const newId = request.method === "DELETE" + ? null + : `assignment-new-${++assignmentNumber}`; + if (current) { + current.current = false; + current.release = { + action: newId ? "replaced" : "unassigned", + actor: "fixture-owner", + at: "2026-01-03T00:00:00Z", + event_id: `release-${assignmentNumber}`, + operation_key: operationKey, + reason: "Explicit fixture operation", + replacement_assignment_id: newId, + last_tracking: structuredClone(current.tracking), + }; + retained.set(current.assignment_id, structuredClone(current)); + } + stored.action_eligibility.can_unassign_orchestrator = newId !== null; + stored.assignment_tracking = []; + stored.assignments = stored.assignments.filter((row) => + row.role !== "orchestrator" + ); + if (newId) { + const row: TicketAssignmentTrackingProjection = { + workspace_id: id, + ticket_id: stored.id, + assignment_id: newId, + assigned_at: "2026-01-03T00:00:00Z", + assigned_by: "fixture-owner", + current: true, + role: "orchestrator", + principal: body.principal, + updated_at: "2026-01-03T00:00:00Z", + last_event_id: `initial-${newId}`, + release: null, + migration_diagnostic: null, + tracking: { + status: "waiting", + waiting_reason: "Initial judgment pending", + next_action: null, + progress: null, + result: null, + }, + }; + stored.assignment_tracking = [row]; + remember(row); + stored.assignments.push({ + assignment_id: newId, + role: row.role, + assigned_at: row.assigned_at, + assigned_by: row.assigned_by, + principal: row.principal, + }); + result = { + workspace_id: id, + ticket_id: stored.id, + assignment: { + workspace_id: id, + ticket_id: stored.id, + ...stored.assignments.at(-1), + }, + }; + } else {result = { + workspace_id: id, + ticket_id: stored.id, + assignment: null, + };} + } else if ( + suffix === "/assignment-answer" || suffix === "/assignment-tracking" + ) { + if ( + !current || current.assignment_id !== body.assignment_id || + current.last_event_id !== body.expected_last_event_id + ) { + return Response.json({ + message: "Stale assignment or observed handling event", + }, { status: 409 }); + } + if (suffix === "/assignment-answer") { + if ( + current.tracking?.status === "done" || stored.state === "closed" + ) { + return Response.json({ + message: "Request a new assignment instead", + }, { status: 409 }); + } + stored.events.push({ + event_ref: `ticket-event:${stored.events.length}`, + kind: "assignment_answer", + body: body.body, + sequence: stored.events.length, + references: [], + attributes: { orchestrator_assignment_id: current.assignment_id }, + }); + stored.event_count = stored.events.length; + result = { + assignment_id: current.assignment_id, + input_event_id: `ticket-event:${stored.events.length - 1}`, + event_index: stored.events.length - 1, + }; + } else { + current.tracking = body.tracking as TicketAssignmentTracking; + current.last_event_id = `handling-update-${ + stored.events.length + + (handlingHistory.get(current.assignment_id)?.length ?? 0) + }`; + current.updated_at = "2026-01-03T00:00:00Z"; + handlingHistory.get(current.assignment_id)!.push({ + at: current.updated_at, + event_id: current.last_event_id, + operation_key: operationKey, + source_context: { actor: "fixture-orchestrator" }, + tracking: structuredClone(current.tracking), + }); + result = current; + } + } else if (suffix === "/tags") { + if ( + JSON.stringify(body.expected_tag_ids.toSorted()) !== + JSON.stringify(stored.tags.map((tag) => tag.id).toSorted()) + ) { + return Response.json({ message: "Tag set conflict" }, { + status: 409, + }); + } + stored.tags = tags.filter((tag) => + !tag.deleted && body.tag_ids.includes(tag.id) + ); + result = stored.tags; + } else { + if ( + body.expected_content_digest !== stored.content_digest || + body.expected_state !== stored.state || + body.expected_closure_operation_key !== stored.closure_operation_key + ) { + return Response.json({ message: "Case judgment conflict" }, { + status: 409, + }); + } + const { action } = body; + stored.closure_history.push({ + action, + at: "2026-01-03T00:00:00Z", + author: "fixture-owner", + explanation: body.explanation, + operation_key: operationKey, + previous_closure_operation_key: stored.closure_operation_key, + references: body.references ?? [], + }); + if (action.action === "reopen") { + stored.state = "open"; + stored.current_closure = null; + } else { + stored.state = "closed"; + stored.closure_operation_key = operationKey; + stored.current_closure = { + at: "2026-01-03T00:00:00Z", + author: "fixture-owner", + explanation: body.explanation, + operation_key: operationKey, + reason: action.reason, + references: body.references ?? [], + }; + } + stored.action_eligibility.can_start_manual_worker = + stored.state === "open"; + result = { + id: stored.id, + resource_key: key, + slug: "fixture", + kind: "task", + labels: [], + tags: stored.tags, + title: stored.title, + priority: stored.priority, + status: stored.state === "open" ? "Open" : "Closed", + workflow_state: stored.state, + workflow_state_explicit: true, + }; + } + receipts.set(operationKey, { + fingerprint, + result: structuredClone(result), + }); + if (injected === "unknown_saved") { + return Response.json({ + message: "Outcome unknown; operation may already be saved", + }, { status: 503 }); + } + return Response.json(result); + } if (request.method !== "GET") { const body = await request.json(); bodies.push({ path, body }); if (url.searchParams.get("failure")) { - return Response.json({ message: "Outcome unknown; refresh before retry" }, { + return Response.json({ + message: "Outcome unknown; refresh before retry", + }, { status: 503, }); } const stored = tickets.get(key) ?? (ticket(key), tickets.get(key)!); - if (body.expected_content_digest && body.expected_content_digest !== stored.content_digest) { - return Response.json({ message: "Ticket content conflict; refresh" }, { status: 409 }); + if ( + body.expected_content_digest && + body.expected_content_digest !== stored.content_digest + ) { + return Response.json( + { message: "Ticket content conflict; refresh" }, + { status: 409 }, + ); } - if ((tm[2] === "/state" || tm[2] === "/close") && body.expected_state !== stored.state) { - return Response.json({ message: "Ticket state conflict; refresh" }, { status: 409 }); - } - if (tm[2] === "/state") { - stored.state = body.state; - stored.events.push({ - sequence: stored.events.length, - kind: "state_changed", - body: body.body, - reason: body.reason, - from: body.expected_state, - to: body.state, - references: [], - attributes: {}, - event_ref: `event-${stored.events.length}`, - }); - } else if (tm[2] === "/events") { + if (tm[2] === "/events") { stored.events.push({ sequence: stored.events.length, kind: body.role, @@ -287,13 +1154,6 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { attributes: {}, event_ref: `event-${stored.events.length}`, }); - } else if (tm[2] === "/close") { - stored.state = "closed"; - stored.resolution = body.resolution; - } else if (tm[2] === "/ready") stored.state = "ready"; - else if (tm[2] === "/queue") { - stored.state = "queued"; - return Response.json({ requested_ticket: key, queued_tickets: [key] }); } else if (request.method === "PATCH") { if (body.target) stored.targets = body.target.targets ?? []; if (body.title) stored.title = body.title; @@ -302,12 +1162,16 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { stored.content_digest = contentDigest(stored); stored.event_count = stored.events.length; } - return Response.json(ticket(key)); + return Response.json(projectedTicket(key, id)); } - return Response.json({ message: "Unavailable fixture resource" }, { status: 404 }); + return Response.json({ message: "Unavailable fixture resource" }, { + status: 404, + }); } const relative = normalize(url.pathname.replace(/^\/+/, "") || "index.html"); - if (relative.startsWith("..")) return new Response("not found", { status: 404 }); + if (relative.startsWith("..")) { + return new Response("not found", { status: 404 }); + } const mime: Record<string, string> = { ".html": "text/html", ".js": "text/javascript", @@ -319,7 +1183,9 @@ Deno.serve({ hostname: "127.0.0.1", port }, async (request) => { try { if ((await Deno.stat(file)).isDirectory) file = join(file, "index.html"); return new Response(await Deno.readFile(file), { - headers: { "content-type": mime[extname(file)] ?? "application/octet-stream" }, + headers: { + "content-type": mime[extname(file)] ?? "application/octet-stream", + }, }); } catch { return new Response(await Deno.readFile(join(root, "index.html")), { diff --git a/tools/web-ux/browser-tests/ticket_principal_test.ts b/tools/web-ux/browser-tests/ticket_principal_test.ts new file mode 100644 index 000000000..a0144dc61 --- /dev/null +++ b/tools/web-ux/browser-tests/ticket_principal_test.ts @@ -0,0 +1,65 @@ +import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; +import { chromium } from "npm:playwright@1.59.1"; +import { loadScenario } from "../src/scenario.ts"; +import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; + +// Native select and confirmed replacement against the loopback browser fixture. +// happy-dom does not match selected options with :checked (used by Svelte's +// select binding); do not substitute fake component state for this boundary. +Deno.test("principal replacement snapshots the target and observed assignment for exact unknown-outcome retry", async () => { + const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname; + const scenario = await loadScenario(scenarioPath); + const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); + const port = (listener.addr as Deno.NetAddr).port; + listener.close(); + const base = `http://127.0.0.1:${port}`; + scenario.processes![0].args![4] = String(port); + scenario.processes![0].readyUrl = `${base}/health`; + const processes = await startOwnedProcesses( + scenario.processes!, + scenarioPath, + new URL(`../../../target/web-ux/t743-principal/${port}`, import.meta.url).pathname, + [], + ); + let browser: Awaited<ReturnType<typeof chromium.launch>> | null = null; + try { + browser = await chromium.launch({ headless: true }); + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets/T-2`); + await page.getByText("Self-reported handling: waiting", { exact: true }).waitFor(); + await page.getByText("Choose responsibility principal", { exact: true }).click(); + await page.getByRole("combobox", { name: "Principal kind", exact: true }).selectOption("user"); + await page.getByLabel("Account identity", { exact: true }).fill("alice"); + await page.getByText("Replace this delegation", { exact: true }).click(); + const sent: string[] = []; + await page.route("**/assignments/orchestrator", async (route) => { + if (route.request().method() === "PUT") { + sent.push(route.request().postData()!); + await route.abort("failed"); + } else await route.continue(); + }); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Replace assignment", exact: true }).click(); + await page.getByRole("alert").filter({ hasText: "Outcome unknown" }).waitFor(); + assertEquals(sent.length, 1); + const input = JSON.parse(sent[0]); + assertEquals(input.principal, { kind: "user", account_id: "alice" }); + assertEquals(input.expected_assignment_id, "assignment-T-2"); + assert(typeof input.operation_id === "string" && input.operation_id.length > 0); + assert(await page.getByRole("combobox", { name: "Principal kind", exact: true }).isDisabled()); + const retryFailed = page.waitForEvent( + "requestfailed", + (request) => + request.method() === "PUT" && request.url().endsWith("/assignments/orchestrator"), + ); + await page.getByRole("button", { name: "Retry exact operation", exact: true }).click(); + // The alert text is unchanged on retry; synchronize on the actual second + // request failure, not a fixed delay or an unrelated Worker state. + await retryFailed; + assertEquals(sent.length, 2); + assertEquals(sent[1], sent[0]); + } finally { + await browser?.close(); + await stopOwnedProcesses(processes); + } +}); diff --git a/tools/web-ux/browser-tests/ticket_shell_test.ts b/tools/web-ux/browser-tests/ticket_shell_test.ts index f0928142c..0591becf2 100644 --- a/tools/web-ux/browser-tests/ticket_shell_test.ts +++ b/tools/web-ux/browser-tests/ticket_shell_test.ts @@ -1,28 +1,601 @@ import { assert, assertEquals } from "jsr:@std/assert@1.0.19"; -import { chromium } from "npm:playwright@1.59.1"; +import { type Browser, chromium, type Page } from "npm:playwright@1.59.1"; import { loadScenario } from "../src/scenario.ts"; import { startOwnedProcesses, stopOwnedProcesses } from "../src/processes.ts"; -// Browser boundary over the production static build and synthetic loopback API. -// No Backend, Worker, Repository, review or integration operation is executed. -Deno.test("Ticket progress, comment results and reopen work without Git while real MR evidence stays separate", async () => { - const scenarioPath = new URL("../scenarios/tickets.json", import.meta.url).pathname; +// Explicit browser boundary: production static build + loopback API fixture. +// These tests prove UI/request semantics, not real Backend delivery or model judgment. +async function withFixture( + run: (browser: Browser, baseUrl: string) => Promise<void>, +) { + const scenarioPath = + new URL("../scenarios/tickets.json", import.meta.url).pathname; const scenario = await loadScenario(scenarioPath); const listener = Deno.listen({ hostname: "127.0.0.1", port: 0 }); const port = (listener.addr as Deno.NetAddr).port; listener.close(); - scenario.baseUrl = `http://127.0.0.1:${port}`; + const baseUrl = `http://127.0.0.1:${port}`; scenario.processes![0].args![4] = String(port); - scenario.processes![0].readyUrl = `${scenario.baseUrl}/health`; - const output = new URL("../../../target/web-ux/t720-browser/", import.meta.url).pathname; + scenario.processes![0].readyUrl = `${baseUrl}/health`; + const logs = + new URL(`../../../target/web-ux/t743-browser/${port}/logs`, import.meta.url) + .pathname; const processes = await startOwnedProcesses( scenario.processes!, scenarioPath, - `${output}/logs`, + logs, [], ); - const browser = await chromium.launch({ headless: true }); + let browser: Browser | null = null; try { + browser = await chromium.launch({ headless: true }); + await run(browser, baseUrl); + } finally { + await browser?.close(); + await stopOwnedProcesses(processes); + } +} +async function detail(base: string, key: string) { + return await (await fetch(`${base}/api/w/tickets/tickets/${key}`)).json(); +} +async function control(base: string, body: unknown) { + const response = await fetch(`${base}/fixture-control`, { + method: "POST", + body: JSON.stringify(body), + }); + assert(response.ok); + await response.json(); +} +function queryResponse( + page: Page, + predicate: (body: Record<string, unknown>) => boolean, +) { + return page.waitForResponse((response) => + response.url().endsWith("/tickets/query") && + response.request().method() === "POST" && + predicate(response.request().postDataJSON()) + ); +} + +Deno.test("Ticket filters and Orchestrator handling query the server beyond loaded pages and preserve filters on pagination", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets-pages/tickets`); + await page.locator('.ticket-lane[data-state="open"] .ticket-card').first() + .waitFor(); + assertEquals( + await page.locator('.ticket-lane[data-state="open"] .ticket-card') + .count(), + 30, + ); + assertEquals( + await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ }) + .count(), + 0, + ); + await page.getByRole("checkbox", { name: "UI", exact: true }).check(); + await page.getByRole("combobox", { + name: "Assigned principal", + exact: true, + }).selectOption("orchestrator"); + const filtered = queryResponse( + page, + (body) => + (body.tag_ids as string[])?.includes("tag-ui") && + (body.assigned_principal as { kind: string })?.kind === + "workspace_agent" && + (body.states as string[]).includes("open"), + ); + await page.getByRole("button", { name: "Apply filters", exact: true }) + .click(); + const response = await filtered; + assertEquals(response.request().postDataJSON(), { + query: null, + states: ["open"], + tag_ids: ["tag-ui"], + assigned_principal: { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, + handling_status: null, + sort: "updated_desc", + limit: 30, + cursor: null, + }); + await page.locator(".ticket-card-id").filter({ hasText: /^T-35$/ }) + .waitFor(); + assertEquals( + await page.locator('.ticket-lane[data-state="open"] .ticket-card') + .count(), + 2, + ); + await page.goto( + `${base}/w/tickets-pages/tickets?case=open&principal=orchestrator&tag=tag-investigation`, + ); + await page.getByRole("button", { name: "Load more open tickets" }) + .waitFor(); + const next = queryResponse(page, (body) => body.cursor === "30"); + await page.getByRole("button", { name: "Load more open tickets" }).click(); + const nextRequest = (await next).request().postDataJSON(); + assertEquals(nextRequest.tag_ids, ["tag-investigation"]); + assertEquals(nextRequest.states, ["open"]); + assertEquals(nextRequest.assigned_principal, { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }); + await page.locator(".ticket-card-id").filter({ hasText: /^T-40$/ }) + .waitFor(); + assertEquals(await page.locator(".ticket-card").count(), 39); + await page.goto(`${base}/w/tickets-pages/orchestrator?handling=done`); + await page.getByRole("button", { name: "Load more assignments" }).waitFor(); + const moreHandling = queryResponse( + page, + (body) => body.cursor === "30" && body.handling_status === "done", + ); + await page.getByRole("button", { name: "Load more assignments" }).click(); + const handlingRequest = (await moreHandling).request().postDataJSON(); + assertEquals(handlingRequest.assigned_principal, { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }); + assertEquals(handlingRequest.states, []); + await page.locator(".orchestrator-assignment").filter({ hasText: "T-40" }) + .waitFor(); + assertEquals(await page.locator(".orchestrator-assignment").count(), 38); + await page.getByRole("combobox", { + name: "Handling (self-reported)", + exact: true, + }).selectOption("waiting"); + const waiting = queryResponse( + page, + (body) => body.handling_status === "waiting" && body.cursor === null, + ); + await page.getByRole("button", { name: "Apply filters", exact: true }) + .click(); + await waiting; + await page.locator(".orchestrator-assignment").filter({ + hasText: "Awaiting the user's policy choice", + }).waitFor(); + assertEquals(await page.locator(".orchestrator-assignment").count(), 1); + assertEquals(await page.getByRole("link", { name: /T-1 / }).count(), 0); + }); +}); + +Deno.test("current close-reason filtering sends canonical known/custom reasons and never closes cases", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets`); + await page.getByText("Close reason filter", { exact: true }).click(); + await page.getByRole("combobox", { + name: "Current close reason", + exact: true, + }).selectOption("completed"); + await page.getByRole("combobox", { name: "Case", exact: true }) + .selectOption("closed"); + const response = queryResponse( + page, + (body) => body.closure_reason === "completed", + ); + await page.getByRole("button", { name: "Apply filters", exact: true }) + .click(); + assertEquals((await response).request().postDataJSON().states, ["closed"]); + await page.locator('.ticket-lane[data-state="open"]').waitFor({ + state: "hidden", + }); + assertEquals(await page.locator(".ticket-card").count(), 1); + assertEquals(await page.locator(".ticket-card-id").textContent(), "T-4"); + await page.getByRole("combobox", { + name: "Current close reason", + exact: true, + }).selectOption("other"); + await page.getByLabel("Close reason text", { exact: true }).fill( + "Superseded plan", + ); + const custom = queryResponse( + page, + (body) => + (body.closure_reason as { other: string })?.other === "Superseded plan", + ); + await page.getByRole("button", { name: "Apply filters", exact: true }) + .click(); + assertEquals((await custom).request().postDataJSON().closure_reason, { + other: "Superseded plan", + }); + await page.getByText("No tickets", { exact: true }).waitFor(); + assertEquals( + (await detail(base, "T-4")).current_closure.reason, + "completed", + ); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + assert( + !requests.some((row: { path: string }) => row.path.endsWith("/case")), + ); + }); +}); + +Deno.test("waiting answers retain a delegation, done reissue retains results, and reasoned close/reopen leaves handling separate", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets/T-2`); + await page.getByText("Self-reported handling: waiting", { exact: true }) + .waitFor(); + const before = await detail(base, "T-2"); + const row = before.assignment_tracking[0]; + await page.getByText("Record result or comment", { exact: true }).click(); + await page.getByLabel("Body", { exact: true }).fill( + "Ordinary retained discussion, not an answer input", + ); + const eventResponse = page.waitForResponse((response) => + response.url().endsWith("/events") && + response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Append event", exact: true }) + .click(); + assertEquals((await eventResponse).request().postDataJSON(), { + role: "comment", + body: "Ordinary retained discussion, not an answer input", + }); + await page.getByText("Ordinary retained discussion, not an answer input", { + exact: true, + }).waitFor(); + assertEquals((await detail(base, "T-2")).assignment_tracking[0], row); + await page.getByLabel("Answer / continue this handling", { exact: true }) + .fill("Choose read-only comparison; keep the same delegation"); + const answerResponse = page.waitForResponse((response) => + response.url().endsWith("/assignment-answer") && + response.request().method() === "POST" + ); + await page.getByRole("button", { + name: "Send answer to this assignment", + exact: true, + }).click(); + const answer = (await answerResponse).request().postDataJSON(); + assertEquals(answer.assignment_id, row.assignment_id); + assertEquals(answer.expected_last_event_id, row.last_event_id); + assertEquals( + answer.body, + "Choose read-only comparison; keep the same delegation", + ); + assert(typeof answer.operation_key === "string"); + assertEquals(Object.keys(answer).toSorted(), [ + "assignment_id", + "body", + "expected_last_event_id", + "operation_key", + ]); + await page.getByRole("status").filter({ + hasText: "Answer saved to assignment", + }).waitFor(); + const after = await detail(base, "T-2"); + assertEquals(after.assignment_tracking[0].assignment_id, row.assignment_id); + assertEquals(after.assignment_tracking[0].tracking.status, "waiting"); + assertEquals(after.events.at(-1).kind, "assignment_answer"); + await page.goto(`${base}/w/tickets/tickets/T-1`); + await page.getByText("Self-reported handling: done", { exact: true }) + .waitFor(); + const original = (await detail(base, "T-1")).assignment_tracking[0]; + const reissueResponse = page.waitForResponse((response) => + response.url().endsWith("/assignments/orchestrator") && + response.request().method() === "PUT" + ); + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).click(); + const request = (await reissueResponse).request().postDataJSON(); + assertEquals(request.expected_assignment_id, original.assignment_id); + assertEquals(request.principal, { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }); + assert(typeof request.operation_id === "string"); + await page.getByText("Self-reported handling: waiting", { exact: true }) + .waitFor(); + const reissued = await detail(base, "T-1"); + assert( + reissued.assignment_tracking[0].assignment_id !== original.assignment_id, + ); + assertEquals(reissued.state, "open"); + await page.getByText("Past assignment and release history", { exact: true }) + .click(); + await page.getByLabel("Assignment identity", { exact: true }).fill( + original.assignment_id, + ); + await page.getByRole("button", { name: "Read assignment", exact: true }) + .click(); + await page.getByText(/Previous result: Compared options/).waitFor(); + await page.getByText(/replaced by fixture-owner/).waitFor(); + await page.goto(`${base}/w/tickets/tickets/T-4`); + await page.getByText("Self-reported handling: done", { exact: true }) + .waitFor(); + await page.locator(".ticket-case-card").getByLabel("Explanation", { + exact: true, + }).fill("Reconsider the case without delegating again"); + const reopenResponse = page.waitForResponse((response) => + response.url().endsWith("/case") && response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Reopen ticket", exact: true }) + .click(); + const reopen = (await reopenResponse).request().postDataJSON(); + assertEquals(reopen.action, { action: "reopen" }); + assertEquals(reopen.expected_state, "closed"); + assertEquals(reopen.expected_closure_operation_key, "close-T-4"); + await page.getByRole("button", { name: "Close ticket", exact: true }) + .waitFor(); + const reopened = await detail(base, "T-4"); + assertEquals(reopened.state, "open"); + assertEquals(reopened.assignment_tracking[0].tracking.status, "done"); + assertEquals(reopened.closure_history.length, 2); + await page.locator(".ticket-case-card").getByLabel("Explanation", { + exact: true, + }).fill("The full requested investigation is delivered"); + await page.locator(".ticket-case-card select").selectOption("completed"); + const closeResponse = page.waitForResponse((response) => + response.url().endsWith("/case") && response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Close ticket", exact: true }) + .click(); + const close = (await closeResponse).request().postDataJSON(); + assertEquals(close.action, { action: "close", reason: "completed" }); + assertEquals(close.expected_state, "open"); + await page.getByRole("button", { name: "Reopen ticket", exact: true }) + .waitFor(); + const closed = await detail(base, "T-4"); + assertEquals(closed.current_closure.reason, "completed"); + assertEquals(closed.tags, reopened.tags); + assertEquals( + closed.assignment_tracking[0].assignment_id, + reopened.assignment_tracking[0].assignment_id, + ); + await page.getByText("Past assignment and release history", { exact: true }) + .click(); + await page.getByLabel("Assignment identity", { exact: true }).fill( + "released-T-4", + ); + await page.getByRole("button", { name: "Read assignment", exact: true }) + .click(); + await page.getByText(/unassigned by fixture-owner/).waitFor(); + await page.getByText( + "Previous result: Delivered the full requested investigation", + { + exact: true, + }, + ).waitFor(); + assert( + await page.getByText("Self-reported handling: done", { exact: true }) + .isVisible(), + ); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + assert( + !requests.some((r: { path: string }) => + /\/(queue|ready|state)$/.test(r.path) + ), + ); + }); +}); + +Deno.test("repository-free creation and canonical tag create/attach/rename/delete do not close or delegate the case", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets`); + await page.getByRole("button", { name: "Add Ticket", exact: true }).click(); + await page.getByLabel("Title", { exact: true }).fill( + "Repository-free investigation", + ); + await page.getByLabel("Body", { exact: true }).fill( + "Compare options and return without deciding", + ); + const createResponse = page.waitForResponse((response) => + response.url().endsWith("/tickets") && + response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Create Ticket", exact: true }) + .click(); + const create = (await createResponse).request().postDataJSON(); + assertEquals(create.targets, []); + assertEquals(create.workflow_state, "open"); + assertEquals(create.title, "Repository-free investigation"); + await page.getByRole("heading", { + name: "Repository-free investigation", + exact: true, + }).waitFor(); + await page.getByText("No current Orchestrator assignment.", { exact: true }) + .waitFor(); + await page.getByText("Manage Workspace tags", { exact: true }).click(); + await page.getByLabel("Tag name", { exact: true }).fill("Comparisons"); + const tagCreate = page.waitForResponse((response) => + response.url().endsWith("/ticket-tags") && + response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Create tag", exact: true }).click(); + const tagResult = await tagCreate; + const tag = await tagResult.json(); + assertEquals(tagResult.request().postDataJSON().action, { + kind: "create", + name: "Comparisons", + }); + await page.getByText("Edit classification", { exact: true }).click(); + await page.getByRole("checkbox", { name: "Comparisons", exact: true }) + .check(); + await page.getByRole("checkbox", { name: "Investigation", exact: true }) + .check(); + const attach = page.waitForResponse((response) => + response.url().endsWith("/tags") && response.request().method() === "PUT" + ); + await page.getByRole("button", { name: "Save classification", exact: true }) + .click(); + const attachRequest = (await attach).request().postDataJSON(); + assertEquals(attachRequest.expected_tag_ids, []); + assertEquals( + attachRequest.tag_ids.toSorted(), + [tag.id, "tag-investigation"].toSorted(), + ); + await page.locator(".ticket-tag-chips").getByText("Comparisons", { + exact: true, + }).waitFor(); + await page.getByRole("combobox", { name: "Existing tag", exact: true }) + .selectOption(tag.id); + await page.getByLabel("Tag name", { exact: true }).fill("Options"); + const renamed = page.waitForResponse((response) => + response.url().endsWith("/ticket-tags") && + response.request().method() === "POST" + ); + await page.getByRole("button", { name: "Rename tag", exact: true }).click(); + const rename = (await renamed).request().postDataJSON(); + assertEquals(rename.action, { + kind: "rename", + tag_id: tag.id, + expected_last_operation_key: tag.last_operation_key, + name: "Options", + }); + await page.locator(".ticket-tag-chips").getByText("Options", { + exact: true, + }).waitFor(); + const deleted = page.waitForResponse((response) => + response.url().endsWith("/ticket-tags") && + response.request().method() === "POST" + ); + page.once("dialog", (dialog) => dialog.accept()); + await page.getByRole("button", { name: "Delete tag", exact: true }).click(); + assertEquals((await deleted).request().postDataJSON().action, { + kind: "delete", + tag_id: tag.id, + expected_last_operation_key: rename.operation_key, + }); + await page.locator(".ticket-tag-chips").getByText("Options", { + exact: true, + }).waitFor({ state: "hidden" }); + const ticket = await detail(base, "T-41"); + assertEquals(ticket.state, "open"); + assertEquals(ticket.assignment_tracking, []); + assertEquals(ticket.tags.map((tag: { id: string }) => tag.id), [ + "tag-investigation", + ]); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + assert( + !requests.some((row: { path: string }) => + /assignment|\/case$/.test(row.path) + ), + ); + }); +}); + +Deno.test("ambiguous saved Assign retries exact JSON once, while stale and permission denials never claim success", async () => { + await withFixture(async (browser, base) => { + const page = await browser.newPage(); + await page.goto(`${base}/w/tickets/tickets/T-1`); + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).waitFor(); + await control(base, { failure: "unknown_saved" }); + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).click(); + await page.getByRole("alert").filter({ hasText: "Outcome unknown" }) + .waitFor(); + assertEquals( + await page.getByRole("status").filter({ + hasText: "Assignment operation saved", + }).count(), + 0, + ); + const saved = + (await detail(base, "T-1")).assignment_tracking[0].assignment_id; + const beforeRetry = await (await fetch(`${base}/fixture-state`)).json(); + const first = beforeRetry.filter((item: { path: string }) => + item.path.endsWith("/assignments/orchestrator") + ); + assertEquals(first.length, 1); + await page.getByRole("button", { + name: "Retry exact operation", + exact: true, + }).click(); + await page.getByRole("status").filter({ + hasText: "Assignment operation saved", + }).waitFor(); + const requests = await (await fetch(`${base}/fixture-state`)).json(); + const assignmentRequests = requests.filter((item: { path: string }) => + item.path.endsWith("/assignments/orchestrator") + ); + assertEquals(assignmentRequests.length, 2); + assertEquals(assignmentRequests[0].body, assignmentRequests[1].body); + assertEquals( + (await detail(base, "T-1")).assignment_tracking[0].assignment_id, + saved, + ); + for (const failure of ["stale", "denied", "unknown_unsaved"] as const) { + await control(base, { + reset: true, + failure: failure === "stale" ? "none" : failure, + }); + await page.goto(`${base}/w/tickets/tickets/T-1`); + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).waitFor(); + let expectedAssignment = "assignment-T-1"; + if (failure === "stale") { + const replacement = await fetch( + `${base}/api/w/tickets/tickets/internal-T-1/assignments/orchestrator`, + { + method: "PUT", + body: JSON.stringify({ + operation_id: "concurrent-replacement", + expected_assignment_id: "assignment-T-1", + principal: { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, + }), + }, + ); + assert(replacement.ok); + expectedAssignment = + (await replacement.json()).assignment.assignment_id; + } + await page.getByRole("button", { + name: "Request again (new assignment)", + exact: true, + }).click(); + await page.getByRole("alert").filter({ + hasText: failure === "stale" + ? "Stale observation" + : failure === "denied" + ? "Permission denied" + : "Outcome unknown", + }).waitFor(); + assertEquals( + (await detail(base, "T-1")).assignment_tracking[0].assignment_id, + expectedAssignment, + ); + assertEquals( + await page.getByRole("status").filter({ + hasText: "Assignment operation saved", + }).count(), + 0, + ); + const attempts = await (await fetch(`${base}/fixture-state`)).json(); + assertEquals( + attempts.filter(( + r: { path: string; body: { operation_id?: string } }, + ) => + r.path.endsWith("/assignments/orchestrator") && + r.body.operation_id !== "concurrent-replacement" + ).length, + 1, + ); + } + }); +}); + +Deno.test("case and handling matrix renders without overflow in both themes and empty/error/unknown states stay distinct", async () => { + await withFixture(async (browser, base) => { + const output = + new URL("../../../target/web-ux/t743-browser/matrix", import.meta.url) + .pathname; + await Deno.mkdir(output, { recursive: true }); for (const colorScheme of ["light", "dark"] as const) { for (const width of [1440, 768, 390]) { const context = await browser.newContext({ @@ -32,227 +605,123 @@ Deno.test("Ticket progress, comment results and reopen work without Git while re }); try { const page = await context.newPage(); - const unexpected: string[] = []; - page.on("pageerror", (e) => unexpected.push(String(e))); - await page.goto(`${scenario.baseUrl}/w/tickets/tickets`); - const addTicket = page.getByRole("button", { name: "Add Ticket", exact: true }); - await addTicket.waitFor(); - const action = await addTicket.boundingBox(); - assert(action && action.x >= 0 && action.x + action.width <= width && action.y < 900); - assertEquals( - await page.evaluate("document.documentElement.scrollWidth > innerWidth"), - false, - ); - await addTicket.click(); - await page.locator(".ticket-create-form").waitFor(); - assertEquals(await page.locator(".ticket-target-row").count(), 0); - assertEquals( - await page.evaluate("document.documentElement.scrollWidth > innerWidth"), - false, - ); - await page.goto( - `${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`, - ); - await page.locator(".ticket-detail-header").waitFor(); - // Intent comes first at one-column widths, unlike the former controls-first layout. - if (width < 1024) { - const intent = await page.getByRole("heading", { name: "Intent", exact: true }) - .boundingBox(); - const progress = await page.getByRole("heading", { name: "Progress decision" }) - .boundingBox(); - assert(intent && progress && intent.y < progress.y); - } - const state = page.getByRole("combobox", { name: "State", exact: true }); - await state.waitFor(); - assertEquals(await state.locator("option").allTextContents(), [ - "planning", - "ready", - "queued", - "inprogress", - "done", - "closed", - ]); - await state.selectOption("inprogress"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "Investigating options directly", - ); - await page.getByRole("button", { name: "Apply state", exact: true }).click(); - await page.locator('.ticket-detail-kicker [data-status="inprogress"]').waitFor(); - await page.getByText("Record result or comment", { exact: true }).click(); - await page.getByLabel("Body", { exact: true }).fill( - `Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`, - ); - await page.getByRole("button", { name: "Append event" }).click(); - await page.getByText( - `Option A and B are available; return to the user without a conclusion (${colorScheme}/${width}).`, - { exact: true }, - ).waitFor(); - await state.selectOption("done"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "Requested comparison returned", - ); - await page.getByLabel("Result, references, and remaining work (optional)").fill( - "Result: timeline comment. [Reference](https://example.com/options). Remaining: user choice.", - ); - await page.getByRole("button", { name: "Complete Ticket", exact: true }).click(); - await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor(); - assertEquals( - await page.getByRole("heading", { name: "MR requirement evidence" }).count(), - 0, - ); - assert(await page.getByText("No Merge Requests linked.", { exact: false }).isVisible()); - await state.selectOption("planning"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "User requests another comparison", - ); - await page.getByRole("button", { name: "Reopen / apply state" }).click(); - await page.locator('.ticket-detail-kicker [data-status="planning"]').waitFor(); - await page.getByRole("button", { name: "Request Orchestrator (queue)" }).click(); - await page.locator('.ticket-detail-kicker [data-status="queued"]').waitFor(); - await page.locator(".ticket-close-card > summary").click(); - await page.getByLabel("Resolution").fill("Discussion returned; no code or MR required"); - await page.getByRole("button", { name: "Close ticket", exact: true }).click(); - await page.locator('.ticket-detail-kicker [data-status="closed"]').waitFor(); - await page.goto(`${scenario.baseUrl}/w/tickets/tickets/T-2-read-only-investigation`); - await page.locator(".ticket-detail-header").waitFor(); - assertEquals( - await page.getByRole("combobox", { name: "Access", exact: true }).inputValue(), - "read_only", - ); - assert(await page.getByRole("button", { name: "Mark ready", exact: true }).isEnabled()); - await page.getByRole("button", { name: "Mark ready", exact: true }).click(); - await page.locator('.ticket-detail-kicker [data-status="ready"]').waitFor(); - assert(await page.getByRole("combobox", { name: "Repository", exact: true }).isEnabled()); - await page.getByRole("button", { name: "Save targets", exact: true }).click(); - await page.getByRole("button", { name: "Save targets", exact: true }).waitFor({ - state: "visible", - }); - await page.goto( - `${scenario.baseUrl}/w/tickets/tickets/T-3-independent-review-before-merge`, - ); - await page.locator(".ticket-detail-header").waitFor(); - await state.selectOption("done"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "Progress decision is separate from review", - ); - await page.getByRole("button", { name: "Complete Ticket" }).click(); - await page.locator('.ticket-detail-kicker [data-status="done"]').waitFor(); - assert(await page.getByText("main: open", { exact: true }).isVisible()); - assert( - await page.getByText("Ticket completion is not MR approval or integration.", { - exact: true, - }).isVisible(), - ); - assertEquals(await page.getByText("main: merged", { exact: true }).count(), 0); - // Member normal operations do not enable owner configuration controls. - assertEquals(await page.getByRole("link", { name: "Secrets", exact: true }).count(), 0); - assertEquals( - await page.evaluate("document.documentElement.scrollWidth > innerWidth"), - false, - ); - await Deno.mkdir(output, { recursive: true }); - await page.screenshot({ - path: `${output}/${colorScheme}-${width}-mr-done.png`, - fullPage: true, - }); - assertEquals(unexpected, []); - // Reset synthetic records for the next viewport using the fixture's ordinary state API. - for (const key of ["T-1", "T-2", "T-3"]) { - const detail = - await (await context.request.get(`${scenario.baseUrl}/api/w/tickets/tickets/${key}`)) - .json(); - await context.request.post(`${scenario.baseUrl}/api/w/tickets/tickets/${key}/state`, { - data: { - state: "planning", - reason: "Fixture reset", - expected_state: detail.state, - expected_content_digest: detail.content_digest, - }, + const errors: string[] = []; + page.on("pageerror", (error) => errors.push(String(error))); + for ( + const [workspace, route, ready] of [ + ["tickets", "tickets", ".ticket-card"], + ["tickets", "orchestrator", ".orchestrator-assignment"], + ["tickets-long", "tickets", ".ticket-card"], + ["tickets-empty", "orchestrator", ".orchestrator-assignments"], + ["tickets-unknown", "orchestrator", ".orchestrator-assignment"], + ["tickets-error", "tickets", ".ticket-lane-page-error"], + ["tickets", "tickets/T-1", ".ticket-detail-header"], + ["tickets", "tickets/T-2", ".ticket-worker-observation strong"], + ["tickets", "tickets/T-3", ".ticket-worker-observation strong"], + ["tickets", "tickets/T-4", ".ticket-detail-header"], + [ + "tickets-readonly", + "tickets/T-2", + ".ticket-worker-observation strong", + ], + [ + "tickets-running", + "tickets/T-2", + ".ticket-worker-observation strong", + ], + ] + ) { + await page.goto(`${base}/w/${workspace}/${route}`); + await page.locator(ready).first().waitFor(); + assertEquals( + await page.evaluate( + "document.documentElement.scrollWidth > innerWidth", + ), + false, + ); + if (workspace === "tickets-empty") { + assert( + await page.getByText( + "No current assignments match these filters.", + { exact: true }, + ).isVisible(), + ); + } + if (workspace === "tickets-error") { + assertEquals( + await page.getByRole("button", { name: "Retry", exact: true }) + .count(), + 2, + ); + assertEquals( + await page.getByText("No tickets", { exact: true }).count(), + 0, + ); + } + if (workspace === "tickets-unknown") { + assertEquals( + await page.getByText("Handling: Unknown (not reported)", { + exact: true, + }).count(), + 4, + ); + } + if (route === "tickets/T-2") { + assert( + await page.getByText("Self-reported handling: waiting", { + exact: true, + }).isVisible(), + ); + assertEquals( + await page.locator(".ticket-worker-observation strong") + .textContent(), + "Stopped", + ); + if (workspace === "tickets-readonly") { + assertEquals( + await page.getByRole("button", { + name: "Replace assignment", + exact: true, + }).count(), + 0, + ); + } + if (workspace === "tickets-running") { + assert( + await page.locator(".ticket-orchestrator-worker").getByText( + "Running", + { exact: true }, + ).isVisible(), + ); + } + } + if (route === "tickets/T-3") { + assert( + await page.getByText("Self-reported handling: inprogress", { + exact: true, + }).isVisible(), + ); + assertEquals( + await page.locator(".ticket-worker-observation strong") + .textContent(), + "Running", + ); + assert( + await page.getByText("main: open", { exact: true }).isVisible(), + ); + } + await page.screenshot({ + path: `${output}/${colorScheme}-${width}-${workspace}-${ + route.replaceAll("/", "-") + }.png`, + fullPage: true, }); } + assertEquals(errors, []); } finally { await context.close(); } } } - const context = await browser.newContext(); - try { - const page = await context.newPage(); - await page.goto(`${scenario.baseUrl}/w/tickets/tickets`); - await page.getByRole("button", { name: "Add Ticket", exact: true }).click(); - await page.getByLabel("Title", { exact: true }).fill("Repository-free investigation"); - await page.getByLabel("Body", { exact: true }).fill( - "Compare options and return without conclusion; review is unnecessary.", - ); - await page.getByRole("button", { name: "Create Ticket", exact: true }).click(); - await page.getByRole("heading", { name: "Repository-free investigation", exact: true }) - .waitFor(); - assert(await page.getByText("No repository targets.", { exact: true }).isVisible()); - // Generic remote launch: a natural request, not an inferred code/review pipeline. - await page.goto( - `${scenario.baseUrl}/w/tickets/workers/new?ticketId=T-4&initialInput=Return%20without%20conclusion.%20Review%20is%20unnecessary.`, - ); - await page.locator(".worker-launch-form").waitFor(); - await page.getByRole("combobox", { name: "Runtime", exact: true }).selectOption("remote"); - const launches: Record<string, unknown>[] = []; - await page.route("**/api/w/tickets/workers", async (route) => { - launches.push(route.request().postDataJSON()); - await route.fulfill({ - status: 403, - json: { error: "permission_denied", message: "Permission denied", diagnostics: [] }, - }); - }); - await page.getByRole("button", { name: "Start Worker", exact: true }).click(); - await page.getByText("permission_denied: Permission denied", { exact: true }).waitFor(); - assertEquals(launches.length, 1); - assertEquals(launches[0].workdir_attachments, []); - assertEquals(launches[0].profile, null); - assertEquals(launches[0].initial_submit, [{ - kind: "text", - content: "Return without conclusion. Review is unnecessary.", - }]); - assertEquals((launches[0].ticket_assignment as { ticket_id: string }).ticket_id, "T-4"); - await page.unroute("**/api/w/tickets/workers"); - for ( - const [status, message] of [[409, "Update conflict."], [403, "Permission denied."], [ - 503, - "Outcome unknown or request rejected.", - ]] as const - ) { - await page.goto( - `${scenario.baseUrl}/w/tickets/tickets/T-1-compare-options-without-deciding`, - ); - await page.locator(".ticket-detail-header").waitFor(); - await page.route( - "**/tickets/*/state", - (route) => route.fulfill({ status, json: { error: "failure", message } }), - ); - await page.getByRole("combobox", { name: "State", exact: true }).selectOption("done"); - await page.getByLabel("Reason", { exact: true }).first().fill( - "Keep this draft on rejection", - ); - await page.getByRole("button", { name: "Complete Ticket" }).click(); - await page.getByRole("alert").getByText(message, { exact: false }).waitFor(); - assertEquals( - await page.getByLabel("Reason", { exact: true }).first().inputValue(), - "Keep this draft on rejection", - ); - assert(await page.locator('.ticket-detail-kicker [data-status="planning"]').isVisible()); - assert(await page.getByRole("button", { name: "Refresh Ticket status" }).isEnabled()); - await page.unroute("**/tickets/*/state"); - } - } finally { - await context.close(); - } - const requests = await (await fetch(`${scenario.baseUrl}/fixture-state`)).json(); - assert( - requests.some((r: { body: { body?: string } }) => - r.body.body?.includes("Remaining: user choice") - ), - ); - assert(!requests.some((r: { path: string }) => /merge|review|workers/.test(r.path))); - } finally { - await browser.close(); - await stopOwnedProcesses(processes); - } + }); }); diff --git a/tools/web-ux/deno.json b/tools/web-ux/deno.json index 0ffbee433..b2774d68e 100644 --- a/tools/web-ux/deno.json +++ b/tools/web-ux/deno.json @@ -11,7 +11,7 @@ "web-ux": "deno run --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys cli.ts", "check": "deno check cli.ts src/*.ts tests/*.ts browser-tests/*.ts", "test": "deno test --allow-env --allow-read --allow-write --allow-run --allow-sys tests", - "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" + "test:browser": "deno test --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys browser-tests/ticket_shell_test.ts browser-tests/ticket_principal_test.ts browser-tests/capture_smoke_test.ts browser-tests/memory_document_shell_test.ts browser-tests/console_history_shell_test.ts browser-tests/workspace_home_shell_test.ts browser-tests/workspace_settings_shell_test.ts browser-tests/workdir_removal_shell_test.ts browser-tests/console_pending_shell_test.ts browser-tests/console_live_shell_test.ts browser-tests/console_live_admission_shell_test.ts" }, "fmt": { "lineWidth": 100, diff --git a/tools/web-ux/scenarios/ticket-principals-dark.json b/tools/web-ux/scenarios/ticket-principals-dark.json new file mode 100644 index 000000000..d91b7a9bc --- /dev/null +++ b/tools/web-ux/scenarios/ticket-principals-dark.json @@ -0,0 +1,123 @@ +{ + "schemaVersion": 1, + "id": "ticket-principals", + "title": "Observed assignment and responsibility principal choice", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "dark", + "reducedMotion": "reduce", + "personas": [ + { + "id": "member", + "label": "Workspace member, not settings owner", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "principal", + "label": "Replace responsibility, not Worker liveness", + "path": "/w/tickets/tickets/T-2", + "goal": "Choose the target for a new observed assignment", + "dataState": "Open + waiting with retained prior assignment", + "ready": { + "kind": "selector", + "selector": ".ticket-handling" + }, + "capturePoints": [ + { + "id": "agent", + "label": "Default Workspace agent", + "interaction": [ + { + "action": "click", + "selector": ".assignment-principal summary" + } + ], + "fullPage": true + }, + { + "id": "account", + "label": "Account target", + "interaction": [ + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Home" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "ArrowDown" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Enter" + } + ], + "ready": { + "kind": "selector", + "selector": ".assignment-principal input" + }, + "fullPage": true + }, + { + "id": "worker", + "label": "Worker principal, separate from actual Orchestrator Worker", + "interaction": [ + { + "action": "press", + "selector": ".assignment-principal select", + "key": "End" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Enter" + } + ], + "ready": { + "kind": "selector", + "selector": ".assignment-principal label:has-text(\"Runtime identity\")" + }, + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "ticket-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/ticket_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/tools/web-ux/scenarios/ticket-principals.json b/tools/web-ux/scenarios/ticket-principals.json new file mode 100644 index 000000000..3c662cebd --- /dev/null +++ b/tools/web-ux/scenarios/ticket-principals.json @@ -0,0 +1,123 @@ +{ + "schemaVersion": 1, + "id": "ticket-principals", + "title": "Observed assignment and responsibility principal choice", + "baseUrl": "http://127.0.0.1:15290", + "colorScheme": "light", + "reducedMotion": "reduce", + "personas": [ + { + "id": "member", + "label": "Workspace member, not settings owner", + "auth": { + "kind": "anonymous" + } + } + ], + "viewports": [ + { + "label": "desktop", + "width": 1440, + "height": 900 + }, + { + "label": "narrow", + "width": 768, + "height": 900 + }, + { + "label": "mobile", + "width": 390, + "height": 844 + } + ], + "routes": [ + { + "id": "principal", + "label": "Replace responsibility, not Worker liveness", + "path": "/w/tickets/tickets/T-2", + "goal": "Choose the target for a new observed assignment", + "dataState": "Open + waiting with retained prior assignment", + "ready": { + "kind": "selector", + "selector": ".ticket-handling" + }, + "capturePoints": [ + { + "id": "agent", + "label": "Default Workspace agent", + "interaction": [ + { + "action": "click", + "selector": ".assignment-principal summary" + } + ], + "fullPage": true + }, + { + "id": "account", + "label": "Account target", + "interaction": [ + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Home" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "ArrowDown" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Enter" + } + ], + "ready": { + "kind": "selector", + "selector": ".assignment-principal input" + }, + "fullPage": true + }, + { + "id": "worker", + "label": "Worker principal, separate from actual Orchestrator Worker", + "interaction": [ + { + "action": "press", + "selector": ".assignment-principal select", + "key": "End" + }, + { + "action": "press", + "selector": ".assignment-principal select", + "key": "Enter" + } + ], + "ready": { + "kind": "selector", + "selector": ".assignment-principal label:has-text(\"Runtime identity\")" + }, + "fullPage": true + } + ] + } + ], + "processes": [ + { + "id": "ticket-fixture", + "command": "deno", + "cwd": "..", + "args": [ + "run", + "--allow-net", + "--allow-read", + "browser-tests/ticket_fixture_server.ts", + "15290", + "../../web/workspace/build" + ], + "readyUrl": "http://127.0.0.1:15290/health" + } + ] +} diff --git a/tools/web-ux/scenarios/tickets-dark.json b/tools/web-ux/scenarios/tickets-dark.json index 74b0e2ffe..6975e108f 100644 --- a/tools/web-ux/scenarios/tickets-dark.json +++ b/tools/web-ux/scenarios/tickets-dark.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "tickets-dark", - "title": "Flexible Ticket progress and optional resources", + "title": "Ticket cases, classification and independent Orchestrator handling", "baseUrl": "http://127.0.0.1:15289", "colorScheme": "dark", "reducedMotion": "reduce", @@ -50,15 +50,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -83,15 +79,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -116,15 +108,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -153,8 +141,8 @@ "id": "list", "label": "Member creates a repository-free Ticket", "path": "/w/tickets/tickets", - "goal": "Create work from intent with optional resources, not a fixed delivery pipeline", - "dataState": "Three lightweight progress lanes, member normal operations", + "goal": "Server-filter open and closed cases by canonical tags and current principal", + "dataState": "Open+done, open+waiting, inprogress with MR, closed with history", "ready": { "kind": "selector", "selector": ".ticket-kanban" @@ -162,7 +150,7 @@ "capturePoints": [ { "id": "initial", - "label": "Progress lanes", + "label": "Case list", "fullPage": true }, { @@ -179,6 +167,201 @@ "selector": ".ticket-create-form" }, "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".ticket-lane[data-state=closed] .ticket-card", + "key": "End" + } + ], + "fullPage": true + }, + { + "id": "reason-filter", + "label": "Current close reason versus historical reason search", + "interaction": [ + { + "action": "click", + "selector": ".ticket-panel-controls button:has-text(\"Cancel\")" + }, + { + "action": "click", + "selector": ".ticket-reason-filter summary" + } + ], + "fullPage": true + } + ] + }, + { + "id": "orchestrator", + "path": "/w/tickets/orchestrator", + "label": "Current self-reported handling", + "goal": "Current self-reported handling", + "dataState": "Waiting, inprogress and done independent of case", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Current self-reported handling", + "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".orchestrator-assignment:last-child", + "key": "End" + } + ], + "fullPage": true + } + ] + }, + { + "id": "closed", + "path": "/w/tickets/tickets/T-4", + "label": "Closed case and retained history", + "goal": "Closed case and retained history", + "dataState": "Completed closure and replaced/unassigned history", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Closed case and retained history", + "fullPage": true + } + ] + }, + { + "id": "permission", + "path": "/w/tickets-readonly/tickets/T-2", + "label": "Read-only assignment", + "goal": "Read-only assignment", + "dataState": "Backend assignment eligibility denied", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read-only assignment", + "fullPage": true + } + ] + }, + { + "id": "long", + "path": "/w/tickets-long/tickets", + "label": "Long list titles and handling", + "goal": "Long list titles and handling", + "dataState": "Long titles and multiline results", + "ready": { + "kind": "selector", + "selector": ".ticket-kanban" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Long list titles and handling", + "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".ticket-lane[data-state=closed] .ticket-card", + "key": "End" + } + ], + "fullPage": true + } + ] + }, + { + "id": "empty", + "path": "/w/tickets-empty/orchestrator", + "label": "No current assignments", + "goal": "No current assignments", + "dataState": "Empty response", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "No current assignments", + "fullPage": true + } + ] + }, + { + "id": "error", + "path": "/w/tickets-error/tickets", + "label": "Query failure recovery", + "goal": "Query failure recovery", + "dataState": "503 query, scoped errors and retry", + "ready": { + "kind": "selector", + "selector": ".ticket-lane-page-error" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Query failure recovery", + "fullPage": true + } + ] + }, + { + "id": "unknown", + "path": "/w/tickets-unknown/orchestrator", + "label": "Unknown historical handling", + "goal": "Unknown historical handling", + "dataState": "Null handling without inference", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unknown historical handling", + "fullPage": true + } + ] + }, + { + "id": "running", + "path": "/w/tickets-running/tickets/T-2", + "label": "Waiting with running Orchestrator Worker", + "goal": "Waiting with running Orchestrator Worker", + "dataState": "Waiting remains waiting while Worker is running", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Waiting with running Orchestrator Worker", + "fullPage": true } ] } diff --git a/tools/web-ux/scenarios/tickets.json b/tools/web-ux/scenarios/tickets.json index 3b94ce688..2605ff64d 100644 --- a/tools/web-ux/scenarios/tickets.json +++ b/tools/web-ux/scenarios/tickets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, "id": "tickets", - "title": "Flexible Ticket progress and optional resources", + "title": "Ticket cases, classification and independent Orchestrator handling", "baseUrl": "http://127.0.0.1:15289", "colorScheme": "light", "reducedMotion": "reduce", @@ -50,15 +50,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -83,15 +79,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -116,15 +108,11 @@ }, { "id": "resolution", - "label": "Resolution and MR state", + "label": "Reasoned case judgment", "interaction": [ { "action": "click", - "selector": ".ticket-close-card > summary" - }, - { - "action": "click", - "selector": ".ticket-close-card textarea" + "selector": ".ticket-case-card textarea" } ], "fullPage": true @@ -153,8 +141,8 @@ "id": "list", "label": "Member creates a repository-free Ticket", "path": "/w/tickets/tickets", - "goal": "Create work from intent with optional resources, not a fixed delivery pipeline", - "dataState": "Three lightweight progress lanes, member normal operations", + "goal": "Server-filter open and closed cases by canonical tags and current principal", + "dataState": "Open+done, open+waiting, inprogress with MR, closed with history", "ready": { "kind": "selector", "selector": ".ticket-kanban" @@ -162,7 +150,7 @@ "capturePoints": [ { "id": "initial", - "label": "Progress lanes", + "label": "Case list", "fullPage": true }, { @@ -179,6 +167,201 @@ "selector": ".ticket-create-form" }, "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".ticket-lane[data-state=closed] .ticket-card", + "key": "End" + } + ], + "fullPage": true + }, + { + "id": "reason-filter", + "label": "Current close reason versus historical reason search", + "interaction": [ + { + "action": "click", + "selector": ".ticket-panel-controls button:has-text(\"Cancel\")" + }, + { + "action": "click", + "selector": ".ticket-reason-filter summary" + } + ], + "fullPage": true + } + ] + }, + { + "id": "orchestrator", + "path": "/w/tickets/orchestrator", + "label": "Current self-reported handling", + "goal": "Current self-reported handling", + "dataState": "Waiting, inprogress and done independent of case", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Current self-reported handling", + "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".orchestrator-assignment:last-child", + "key": "End" + } + ], + "fullPage": true + } + ] + }, + { + "id": "closed", + "path": "/w/tickets/tickets/T-4", + "label": "Closed case and retained history", + "goal": "Closed case and retained history", + "dataState": "Completed closure and replaced/unassigned history", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Closed case and retained history", + "fullPage": true + } + ] + }, + { + "id": "permission", + "path": "/w/tickets-readonly/tickets/T-2", + "label": "Read-only assignment", + "goal": "Read-only assignment", + "dataState": "Backend assignment eligibility denied", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Read-only assignment", + "fullPage": true + } + ] + }, + { + "id": "long", + "path": "/w/tickets-long/tickets", + "label": "Long list titles and handling", + "goal": "Long list titles and handling", + "dataState": "Long titles and multiline results", + "ready": { + "kind": "selector", + "selector": ".ticket-kanban" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Long list titles and handling", + "fullPage": true + }, + { + "id": "last-case", + "label": "Closed case and final handling result", + "interaction": [ + { + "action": "press", + "selector": ".ticket-lane[data-state=closed] .ticket-card", + "key": "End" + } + ], + "fullPage": true + } + ] + }, + { + "id": "empty", + "path": "/w/tickets-empty/orchestrator", + "label": "No current assignments", + "goal": "No current assignments", + "dataState": "Empty response", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "No current assignments", + "fullPage": true + } + ] + }, + { + "id": "error", + "path": "/w/tickets-error/tickets", + "label": "Query failure recovery", + "goal": "Query failure recovery", + "dataState": "503 query, scoped errors and retry", + "ready": { + "kind": "selector", + "selector": ".ticket-lane-page-error" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Query failure recovery", + "fullPage": true + } + ] + }, + { + "id": "unknown", + "path": "/w/tickets-unknown/orchestrator", + "label": "Unknown historical handling", + "goal": "Unknown historical handling", + "dataState": "Null handling without inference", + "ready": { + "kind": "selector", + "selector": ".orchestrator-assignments" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Unknown historical handling", + "fullPage": true + } + ] + }, + { + "id": "running", + "path": "/w/tickets-running/tickets/T-2", + "label": "Waiting with running Orchestrator Worker", + "goal": "Waiting with running Orchestrator Worker", + "dataState": "Waiting remains waiting while Worker is running", + "ready": { + "kind": "selector", + "selector": ".ticket-detail-header" + }, + "capturePoints": [ + { + "id": "initial", + "label": "Waiting with running Orchestrator Worker", + "fullPage": true } ] } diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 5cc5000c9..1cef3e768 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index b8b63d7a0..f0cf6e62c 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238) +// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index f65371b66..b7846bdab 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238) +// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index e6e9e6411..0931ce7df 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238) +// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check @@ -151,7 +151,7 @@ export type RuntimeWorkerSpawnWorkingDirectoryRequest = { repository_key: string export type RuntimeWorkerSummary = { diagnostics?: Array<Diagnostic>; display_name?: string; host_id: string; implementation: WorkerImplementationSummary; label: string; last_seen_at?: string | null; pinned?: boolean; profile?: string | null; retention_state?: string; runtime_id: string; singleton_key?: string | null; state: string; tags?: Array<string>; workdir_attachments?: Array<unknown>; worker_id: string; worker_state?: unknown; workspace: WorkerWorkspaceSummary; }; -export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; }; +export type RuntimeWorkerTicketAssignmentRequest = { operation_id: string; orchestrator_assignment_id?: string | null; ticket_id: string; }; export type Segment = { content: string; kind: "text"; } | { chars: number; content: string; id: number; kind: "paste"; lines: number; } | { artifact: PasteArtifactRef; kind: "paste_artifact"; } | { file: UploadedFileRef; kind: "uploaded_file"; } | { kind: "file_ref"; path: string; } | { invocation: FeatureInvocation; kind: "feature_invoke"; } | { kind: "flow"; selector: string; } | { kind: "unknown"; }; diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index f14cd9755..d35ec20d0 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,26 +1,26 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238) +// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check -export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; +export type Array_of_TicketAssignmentTrackingProjection = Array<TicketAssignmentTrackingProjection>; -export type BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; }; +export type Array_of_WorkspaceTag = Array<WorkspaceTag>; + +export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; }; export type BrowserQueueTicketRequest = { [key: string]: never; }; +export type BrowserTicketState = "open" | "closed"; + export type BrowserTicketTargetEdit = { action: "set"; targets: Array<TicketTarget>; } | { action: "clear"; }; export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report"; -export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed"; - -export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; }; - export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; }; export type ConflictResolution = "none" | "clean" | "conflicts_resolved"; @@ -29,6 +29,8 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe export type DiagnosticSeverity = "info" | "warning" | "error"; +export type ExtensibleTicketStatus = "Open" | "Closed" | { Other: string; }; + export type FindingSeverity = "blocker" | "major" | "minor" | "note"; export type InvalidProjectRecord = { label: string; reason: string; }; @@ -57,7 +59,7 @@ export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string export type MergeStrategy = "fast_forward" | "merge"; -export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketWorkflowState | null; }; +export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketState | null; }; export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; state: string; title: string; updated_at?: string | null; }; @@ -81,17 +83,61 @@ export type ReviewDecision = "approve" | "request_changes"; export type ReviewFinding = { body: string; code?: string | null; line?: number | null; path?: string | null; severity: FindingSeverity; }; -export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; }; +export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | null; operation_id: string; orchestrator_assignment_id?: string | null; principal: TicketAssignmentPrincipal; workdir_bindings?: Array<TicketWorkerAttachmentBinding>; }; export type TicketActionEligibility = { blockers: Array<string>; can_assign_orchestrator: boolean; can_queue: boolean; can_start_manual_worker: boolean; can_unassign_orchestrator: boolean; queue_tickets: Array<string>; }; +export type TicketAssignmentAnswer = { assignment_id: string; body: string; expected_last_event_id: string; operation_key: string; }; + +export type TicketAssignmentAnswerReceipt = { assignment_id: string; event_index: number; input_event_id: string; }; + +export type TicketAssignmentDiagnosticsPage = { actionable: boolean; assignment_id: string; current: boolean; input_origin_known: boolean; inputs: Array<TicketAssignmentInputDiagnostic>; next_input_event_id?: string | null; next_start_operation_id?: string | null; starts: Array<TicketAssignmentStartDiagnostic>; }; + +export type TicketAssignmentInputDeliveryState = "not_reserved" | "reserved" | "request_persisted" | "runtime_accepted"; + +export type TicketAssignmentInputDiagnostic = { acceptance_outcome_unknown: boolean; delivery_id?: string | null; input_event_id: string; notification: TicketAssignmentInputDeliveryState; }; + export type TicketAssignmentPrincipal = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; }; +export type TicketAssignmentPrincipalSnapshot = { account_id: string; kind: "user"; } | { kind: "worker"; runtime_id: string; worker_id: string; } | { agent_key: string; kind: "workspace_agent"; }; + +export type TicketAssignmentRelease = { action: TicketAssignmentReleaseAction; actor: string; at: string; event_id: string; last_tracking?: TicketAssignmentTracking | null; operation_key?: string | null; reason?: string | null; replacement_assignment_id?: string | null; }; + +export type TicketAssignmentReleaseAction = "unassigned" | "replaced"; + export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contributor"; +export type TicketAssignmentStartDiagnostic = { actual_processing_unknown: boolean; assignment_id?: string | null; claim_state: string; operation_id: string; runtime_id?: string | null; worker_id?: string | null; }; + export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; }; +export type TicketAssignmentTracking = { next_action?: string | null; progress?: string | null; result?: string | null; status: TicketAssignmentTrackingStatus; waiting_reason?: string | null; }; + +export type TicketAssignmentTrackingHistoryEntry = { at: string; event_id: string; operation_key: string; source_context: unknown; tracking: TicketAssignmentTracking; }; + +export type TicketAssignmentTrackingHistoryPage = { items: Array<TicketAssignmentTrackingHistoryEntry>; next_event_id?: string | null; }; + +export type TicketAssignmentTrackingProjection = { assigned_at: string; assigned_by: string; assignment_id: string; current: boolean; last_event_id?: string | null; migration_diagnostic?: string | null; principal: TicketAssignmentPrincipalSnapshot; release?: TicketAssignmentRelease | null; role: string; ticket_id: string; tracking?: TicketAssignmentTracking | null; updated_at?: string | null; workspace_id: string; }; + +export type TicketAssignmentTrackingStatus = "waiting" | "inprogress" | "done"; + +export type TicketAssignmentTrackingUpdate = { assignment_id: string; expected_last_event_id: string; operation_key: string; tracking: TicketAssignmentTracking; }; + +export type TicketAssignmentsPage = { items: Array<TicketAssignmentTrackingProjection>; next_assignment_id?: string | null; }; + +export type TicketAttention = "unresolved_review" | "missing_commit" | "blocked" | "unblocked" | "awaiting_review" | "unresolved_changes" | "stale_after_rescope" | "missing_evidence"; + +export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; }; + +export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; author: string; explanation: string; operation_key: string; previous_closure_operation_key?: string | null; references: Array<TicketReference>; }; + +export type TicketCaseUpdateRequest = { action: TicketCaseAction; expected_closure_operation_key: string | null; expected_content_digest: string; expected_state: TicketState; explanation: string; operation_key: string; references?: Array<TicketReference>; }; + +export type TicketClosureJudgment = { at: string; author: string; explanation: string; operation_key: string; reason: TicketClosureReason; references: Array<TicketReference>; }; + +export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; }; + +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignment_tracking: Array<TicketAssignmentTrackingProjection>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: BrowserTicketState; tags: Array<WorkspaceTag>; targets: Array<TicketTarget>; title: string; updated_at?: string | null; }; export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; @@ -111,7 +157,9 @@ export type TicketEvidenceSummary = { approved_current_subject: boolean; complet export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; }; -export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; title: string; updated_at?: string | null; workspace_action_priority: string; }; +export type TicketLegacyWorkflowSnapshot = { queued_at?: string | null; queued_by?: string | null; resolution?: string | null; status: string; workflow_state: string; workflow_state_explicit: boolean; }; + +export type TicketListItemSummary = { closure_operation_key?: string | null; current_closure?: TicketClosureJudgment | null; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: BrowserTicketState; tags?: Array<WorkspaceTag>; title: string; updated_at?: string | null; workspace_action_priority: string; }; export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<TicketListItemSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; }; @@ -119,6 +167,12 @@ export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | nu export type TicketMergeRequestSummary = { current_subject_ref?: string | null; integration_evidence_error?: string | null; merge_request_id: string; repository_key: string; review_excerpt?: string | null; review_requested_at?: string | null; review_status: string; review_subject_ref?: string | null; review_submitted_at?: string | null; selector_from?: string | null; selector_to: string; source_ref_observation: TicketSourceRefObservation; state: string; updated_at: string; }; +export type TicketQueryItem = { assignment_tracking: Array<TicketAssignmentTrackingProjection>; blocker_count: number; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objective_ids: Array<string>; linked_objective_keys: Array<string>; matched_fields: Array<string>; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: BrowserTicketState; tags: Array<WorkspaceTag>; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; }; + +export type TicketQueryRequest = { assigned_principal?: TicketAssignmentPrincipalSnapshot | null; attention?: Array<TicketAttention>; closure_reason?: TicketClosureReason | null; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; handling_status?: TicketAssignmentTrackingStatus | null; limit?: number & { readonly "__yoiIntegerRange": "0..1000"; } | null; linked_objective_id?: string | null; query?: string | null; related_ticket_id?: string | null; relation_kind?: string | null; review_status?: string | null; sort?: string | null; states?: Array<TicketState>; tag_ids?: Array<string>; tag_ids_any?: Array<string>; tag_ids_none?: Array<string>; updated_after?: string | null; updated_before?: string | null; }; + +export type TicketQueryResponse = { items: Array<TicketQueryItem>; page: QueryPage; record_authority: string; }; + export type TicketQueueOutcome = { queued_tickets: Array<string>; requested_ticket: string; }; export type TicketRef = { id: string; resource_key?: string | null; slug: string; status: TicketStatus; }; @@ -133,12 +187,22 @@ export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: st export type TicketSourceRefObservation = { status: "observed"; } | { code: string; status: "unavailable"; } | { status: "not_required"; }; +export type TicketState = "open" | "closed"; + export type TicketStatus = "Open" | "Closed"; +export type TicketSummary = { id: string; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; resource_key?: string | null; slug: string; status: ExtensibleTicketStatus; tags?: Array<WorkspaceTag>; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; }; + +export type TicketTagSetRequest = { expected_tag_ids: Array<string>; operation_key: string; tag_ids: Array<string>; }; + export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; }; export type TicketTargetAccess = "read_only" | "read_write"; export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; }; -export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed"; +export type WorkspaceTag = { deleted: boolean; id: string; last_operation_key: string; name: string; workspace_id: string; }; + +export type WorkspaceTagAction = { kind: "create"; name: string; } | { expected_last_operation_key: string; kind: "rename"; name: string; tag_id: string; } | { expected_last_operation_key: string; kind: "delete"; tag_id: string; }; + +export type WorkspaceTagMutation = { action: WorkspaceTagAction; operation_key: string; }; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index b40098ed6..535876288 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 6b2b5184f..6ec34e9cd 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a23a7b9989d37fd08459929a4e726396a33334489207799e02d45302e8d4c238) +// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check @@ -13,7 +13,7 @@ export type BrowserWorkspaceOrchestratorResponse = { diagnostics: Array<Diagnost export type CreateWorkspaceWorkerRequest = { control_operation_id?: string | null; display_name: string; feature_connections: WorkspaceWorkerFeatureConnectionsRequest; initial_submit?: Array<Segment>; profile?: string | null; runtime_id: string; singleton_key?: string | null; ticket_assignment?: CreateWorkspaceWorkerTicketAssignmentRequest | null; workdir_attachments?: Array<BrowserWorkerWorkingDirectorySelection>; }; -export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; ticket_id: string; }; +export type CreateWorkspaceWorkerTicketAssignmentRequest = { operation_id: string; orchestrator_assignment_id?: string | null; ticket_id: string; }; export type Diagnostic = { code: string; message: string; severity: DiagnosticSeverity; }; diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts index d3b2499d3..23735a03b 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -10,6 +10,7 @@ import { parseTicketListResponse, parseTicketRecordRef, parseTicketRoleAssignmentMutationResponse, + parseTicketSummary, } from "./ticket-browser.ts"; function assertEquals(actual: unknown, expected: unknown): void { @@ -48,7 +49,8 @@ Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => id: "ticket-id", resource_key: "T-1", title: "Ticket", - state: "ready", + tags: [], + state: "open", priority: "P2", updated_at: null, queued_by: null, @@ -105,16 +107,23 @@ Deno.test("Ticket Browser accepts generic Worker eligibility and assignments", ( assigned_at: "2026-10-06T00:00:00Z", assigned_by: "user-1", assignment_id: "assignment-1", - principal: { kind: "worker", runtime_id: "runtime-1", worker_id: "worker-1" }, + principal: { + kind: "worker", + runtime_id: "runtime-1", + worker_id: "worker-1", + }, role: "worker", ticket_id: "ticket-id", workspace_id: "workspace-a", }; - assertEquals(parseTicketRoleAssignmentMutationResponse({ + assertEquals( + parseTicketRoleAssignmentMutationResponse({ + assignment, + ticket_id: "ticket-id", + workspace_id: "workspace-a", + }).assignment, assignment, - ticket_id: "ticket-id", - workspace_id: "workspace-a", - }).assignment, assignment); + ); }); function ticketDetailFixture() { @@ -130,7 +139,12 @@ function ticketDetailFixture() { artifact_count: 0, artifacts: [], assignment_diagnostics: [], + assignment_tracking: [], assignments: [], + closure_history: [], + closure_operation_key: null, + current_closure: null, + legacy_handoff: null, body: "Body", body_truncated: false, current_worker: null, @@ -164,7 +178,8 @@ function ticketDetailFixture() { resolution: null, resource_key: "T-1", risk_flags: [], - state: "planning", + state: "open", + tags: [], targets: [ { repository_key: "docs", @@ -181,6 +196,52 @@ function ticketDetailFixture() { }; } +Deno.test("Ticket Browser keeps self-reported delegation independent of case and Worker state", () => { + const tracking = { + workspace_id: "workspace-a", + ticket_id: "ticket-id", + assignment_id: "delegation", + role: "orchestrator", + principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + assigned_by: "workspace-user:alice", + assigned_at: "2026-01-01T00:00:00Z", + release: null, + current: true, + last_event_id: "report-event", + updated_at: null, + migration_diagnostic: null, + tracking: { + status: "done", + waiting_reason: null, + progress: "Investigated", + next_action: "Human decision", + result: "Alternatives returned", + }, + }; + for (const state of ["open", "closed"]) { + const parsed = parseTicketDetail({ + ...ticketDetailFixture(), + state, + assignment_tracking: [tracking], + }); + assertEquals(parsed.assignment_tracking[0]?.tracking?.status, "done"); + assertEquals(parsed.assignment_tracking[0]?.last_event_id, "report-event"); + assertEquals(parsed.state, state); + assertEquals(parsed.current_worker, null); + } + assertThrows( + () => + parseTicketDetail({ + ...ticketDetailFixture(), + assignment_tracking: [{ + ...tracking, + tracking: { ...tracking.tracking, status: "running" }, + }], + }), + ".status is invalid", + ); +}); + Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => { const fixture = ticketDetailFixture(); const parsed = parseTicketDetail(fixture); @@ -342,11 +403,11 @@ Deno.test("Merge Request summaries require a nullable bounded integration eviden } }); -Deno.test("done Ticket state is preserved when current requirement evidence is incomplete", () => { +Deno.test("closed Ticket state is preserved when current requirement evidence is incomplete", () => { const fixture = ticketDetailFixture(); const parsed = parseTicketDetail({ ...fixture, - state: "done", + state: "closed", evidence: { ...fixture.evidence, missing: ["review_after_rescope"] }, merge_requests: [{ ...mergeRequestSummary, @@ -354,7 +415,7 @@ Deno.test("done Ticket state is preserved when current requirement evidence is i source_ref_observation: { status: "not_required" }, }], }); - assertEquals(parsed.state, "done"); + assertEquals(parsed.state, "closed"); assertEquals(parsed.evidence.complete_for_integration, false); assertEquals(parsed.merge_requests[0]?.review_status, "approved"); }); @@ -440,3 +501,213 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread", "unknown field unexpected", ); }); + +const tags = ["ready", "closed"].map((name) => ({ + workspace_id: "workspace-a", + id: `tag-${name}`, + name, + last_operation_key: `create-${name}`, + deleted: false, +})); + +const closure = { + operation_key: "close-1", + reason: "not_planned", + explanation: "Not planned for this scope.", + references: [{ kind: "document", target: "result.md" }], + author: "user:alice", + at: "2026-10-10T00:00:00Z", +}; +const closureHistory = [ + { + ...closure, + reason: undefined, + previous_closure_operation_key: null, + action: { action: "close", reason: "not_planned" }, + }, + { + ...closure, + reason: undefined, + operation_key: "correct-1", + previous_closure_operation_key: "close-1", + action: { action: "correct_closure", reason: { other: "Deferred" } }, + }, + { + ...closure, + reason: undefined, + operation_key: "reopen-1", + previous_closure_operation_key: "correct-1", + action: { action: "reopen" }, + }, +].map(({ reason: _, ...entry }) => ({ + operation_key: entry.operation_key, + previous_closure_operation_key: entry.previous_closure_operation_key, + action: entry.action, + explanation: entry.explanation, + references: entry.references, + author: entry.author, + at: entry.at, +})); +const legacyHandoff = { + workflow_state: "done", + workflow_state_explicit: true, + status: "Closed", + queued_by: "workspace-web", + queued_at: "2026-10-09T00:00:00Z", + resolution: "Historical result, not a current case state", +}; + +Deno.test("tags named ready and closed coexist with case judgment and retained reopen history without inferring state", () => { + const fixture = { + ...ticketDetailFixture(), + state: "open", + tags, + closure_operation_key: "correct-1", + current_closure: null, + closure_history: closureHistory, + legacy_handoff: legacyHandoff, + }; + const parsed = parseTicketDetail(fixture); + assertEquals(parsed.state, "open"); + assertEquals(parsed.closure_operation_key, "correct-1"); + assertEquals(parsed.closure_history, closureHistory); + assertEquals(parsed.legacy_handoff, legacyHandoff); + assertEquals(parsed.tags, tags); + const closed = parseTicketDetail({ + ...fixture, + state: "closed", + current_closure: closure, + }); + assertEquals(closed.state, "closed"); + assertEquals(closed.current_closure, closure); + assertEquals(closed.closure_history, closureHistory); + assertEquals(closed.tags, tags); + const list = parseTicketListResponse({ + workspace_id: "workspace-a", + limit: 30, + page, + invalid_records: [], + record_authority: "sqlite", + items: [{ + id: "ticket-id", + resource_key: "T-1", + title: "Ticket", + state: "closed", + tags, + priority: "P2", + record_source: "sqlite", + workspace_action_priority: "background", + closure_operation_key: "close-1", + current_closure: closure, + legacy_handoff: legacyHandoff, + }], + }); + assertEquals(list.items[0].state, "closed"); + assertEquals(list.items[0].tags, tags); + assertEquals(list.items[0].current_closure, closure); + assertEquals(list.items[0].legacy_handoff, legacyHandoff); +}); + +Deno.test("Ticket tag projections reject missing collections and malformed identities, types and unknown fields", () => { + const parsers = [ + (tags: unknown) => parseTicketDetail({ ...ticketDetailFixture(), tags }), + (tags: unknown) => + parseTicketSummary({ + id: "ticket-id", + slug: "ticket", + title: "Ticket", + kind: "task", + labels: [], + priority: "P2", + status: "Open", + workflow_state: "open", + workflow_state_explicit: true, + tags, + }), + (tags: unknown) => + parseTicketListResponse({ + workspace_id: "workspace-a", + limit: 30, + page, + invalid_records: [], + record_authority: "sqlite", + items: [{ + id: "ticket-id", + resource_key: "T-1", + title: "Ticket", + state: "open", + priority: "P2", + record_source: "sqlite", + workspace_action_priority: "background", + tags, + }], + }), + ]; + const tag = tags[0]; + for (const parse of parsers) { + for (const value of [undefined, null, {}, "ready"]) { + assertThrows(() => parse(value), "tags must be an array"); + } + for ( + const [value, message] of [ + [{ ...tag, id: undefined }, "id must be a string"], + [{ ...tag, id: 1 }, "id must be a string"], + [{ ...tag, id: " " }, "id must be nonempty"], + [{ ...tag, id: "é".repeat(65) }, "at most 128 UTF-8 bytes"], + [{ ...tag, workspace_id: false }, "workspace_id must be a string"], + [{ ...tag, name: null }, "name must be a string"], + [ + { ...tag, last_operation_key: 1 }, + "last_operation_key must be a string", + ], + [{ ...tag, deleted: "false" }, "deleted must be a boolean"], + [{ ...tag, state: "closed" }, "unknown field state"], + ] as const + ) { + assertThrows(() => parse([value]), message); + } + } +}); + +Deno.test("case decoders reject legacy current states and malformed closure judgments", () => { + const fixture = ticketDetailFixture(); + for (const state of ["planning", "ready", "queued", "inprogress", "done"]) { + assertThrows( + () => parseTicketDetail({ ...fixture, state }), + "state is invalid", + ); + } + for (const reason of ["done", { other: 1 }, { other: "ok", extra: true }]) { + assertThrows( + () => + parseTicketDetail({ + ...fixture, + current_closure: { ...closure, reason }, + }), + "Ticket detail.current_closure.reason", + ); + } + for ( + const action of ["reopen", { action: "close" }, { + action: "reopen", + reason: "completed", + }, { action: "complete", reason: "completed" }] + ) { + assertThrows( + () => + parseTicketDetail({ + ...fixture, + closure_history: [{ ...closureHistory[0], action }], + }), + "Ticket detail.closure_history[0].action", + ); + } + assertThrows( + () => parseTicketDetail({ ...fixture, closure_history: {} }), + "closure_history must be an array", + ); + assertThrows( + () => parseTicketDetail({ ...fixture, closure_operation_key: 1 }), + "closure_operation_key must be a string", + ); +}); diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index da2ce4ba7..ade684b98 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -1,4 +1,5 @@ import type { + BrowserTicketState, InvalidProjectRecord, MergeRequestDetailResponse, MergeRequestLinkedTicketResponse, @@ -19,7 +20,14 @@ import type { ReviewFinding, TicketActionEligibility, TicketAssignmentPrincipal, + TicketAssignmentRelease, TicketAssignmentSummary, + TicketAssignmentTracking, + TicketAssignmentTrackingProjection, + TicketCaseAction, + TicketCaseHistoryEntry, + TicketClosureJudgment, + TicketClosureReason, TicketDetail, TicketDetailDerivedRelation, TicketDetailRelation, @@ -29,15 +37,21 @@ import type { TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary, + TicketLegacyWorkflowSnapshot, TicketListItemSummary, TicketListResponse, TicketMergeRequestSummary, + TicketQueryItem, + TicketQueryResponse, TicketQueueOutcome, TicketRef, + TicketReference, TicketRoleAssignmentMutationResponse, TicketRoleAssignmentRecord, TicketRoleAssignmentSummary, + TicketSummary, TicketTarget, + WorkspaceTag, } from "#lib/generated/ticket-api.ts"; export const TICKET_BROWSER_API_MAX_RESPONSE_BYTES = 4 * 1024 * 1024; @@ -183,12 +197,238 @@ function parseInvalidProjectRecord( }; } +function parseTicketState(value: unknown, label: string): BrowserTicketState { + if (value !== "open" && value !== "closed") { + throw new Error(`${label} is invalid`); + } + return value; +} + +function parseClosureReason( + value: unknown, + label: string, +): TicketClosureReason { + if ( + typeof value === "string" && + ["completed", "not_planned", "duplicate", "unknown"].includes(value) + ) { + return value as TicketClosureReason; + } + const item = object(value, label); + exact(item, ["other"], label); + return { other: string(item.other, `${label}.other`) }; +} + +function parseCaseAction(value: unknown, label: string): TicketCaseAction { + const item = object(value, label); + if (item.action === "reopen") { + exact(item, ["action"], label); + return { action: "reopen" }; + } + if (item.action !== "close" && item.action !== "correct_closure") { + throw new Error(`${label}.action is invalid`); + } + exact(item, ["action", "reason"], label); + return { + action: item.action, + reason: parseClosureReason(item.reason, `${label}.reason`), + }; +} + +function parseReference(value: unknown, label: string): TicketReference { + const item = object(value, label); + exact(item, ["kind", "target"], label); + return { + kind: string(item.kind, `${label}.kind`), + target: string(item.target, `${label}.target`), + }; +} + +function parseClosure(value: unknown, label: string): TicketClosureJudgment { + const item = object(value, label); + exact(item, [ + "operation_key", + "reason", + "explanation", + "references", + "author", + "at", + ], label); + return { + operation_key: string(item.operation_key, `${label}.operation_key`), + reason: parseClosureReason(item.reason, `${label}.reason`), + explanation: string(item.explanation, `${label}.explanation`), + references: array(item.references, `${label}.references`, parseReference), + author: string(item.author, `${label}.author`), + at: string(item.at, `${label}.at`), + }; +} + +function parseCaseHistory( + value: unknown, + label: string, +): TicketCaseHistoryEntry { + const item = object(value, label); + exact(item, [ + "operation_key", + "previous_closure_operation_key", + "action", + "explanation", + "references", + "author", + "at", + ], label); + return { + operation_key: string(item.operation_key, `${label}.operation_key`), + previous_closure_operation_key: optionalNullableString( + item.previous_closure_operation_key, + `${label}.previous_closure_operation_key`, + ), + action: parseCaseAction(item.action, `${label}.action`), + explanation: string(item.explanation, `${label}.explanation`), + references: array(item.references, `${label}.references`, parseReference), + author: string(item.author, `${label}.author`), + at: string(item.at, `${label}.at`), + }; +} + +function parseLegacyHandoff( + value: unknown, + label: string, +): TicketLegacyWorkflowSnapshot { + const item = object(value, label); + exact(item, [ + "workflow_state", + "workflow_state_explicit", + "status", + "queued_by", + "queued_at", + "resolution", + ], label); + return { + workflow_state: string(item.workflow_state, `${label}.workflow_state`), + workflow_state_explicit: boolean( + item.workflow_state_explicit, + `${label}.workflow_state_explicit`, + ), + status: string(item.status, `${label}.status`), + queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), + queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), + resolution: optionalNullableString(item.resolution, `${label}.resolution`), + }; +} + +function caseProjection(item: Record<string, unknown>, label: string) { + return { + closure_operation_key: optionalNullableString( + item.closure_operation_key, + `${label}.closure_operation_key`, + ), + current_closure: item.current_closure === undefined + ? undefined + : item.current_closure === null + ? null + : parseClosure(item.current_closure, `${label}.current_closure`), + legacy_handoff: item.legacy_handoff === undefined + ? undefined + : item.legacy_handoff === null + ? null + : parseLegacyHandoff(item.legacy_handoff, `${label}.legacy_handoff`), + }; +} + +export function parseWorkspaceTag( + value: unknown, + label = "Workspace tag", +): WorkspaceTag { + const item = object(value, label); + exact(item, [ + "workspace_id", + "id", + "name", + "last_operation_key", + "deleted", + ], label); + const id = string(item.id, `${label}.id`); + if (id.trim().length === 0 || new TextEncoder().encode(id).length > 128) { + throw new Error(`${label}.id must be nonempty and at most 128 UTF-8 bytes`); + } + return { + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + id, + name: string(item.name, `${label}.name`), + last_operation_key: string( + item.last_operation_key, + `${label}.last_operation_key`, + ), + deleted: boolean(item.deleted, `${label}.deleted`), + }; +} + +export function parseTicketSummary(value: unknown): TicketSummary { + const label = "Ticket summary"; + const item = object(value, label); + exact(item, [ + "id", + "kind", + "labels", + "priority", + "queued_at", + "queued_by", + "readiness", + "resource_key", + "slug", + "status", + "tags", + "title", + "updated_at", + "workflow_state", + "workflow_state_explicit", + ], label); + let status: TicketSummary["status"]; + if (item.status === "Open" || item.status === "Closed") status = item.status; + else { + const other = object(item.status, `${label}.status`); + exact(other, ["Other"], `${label}.status`); + status = { Other: string(other.Other, `${label}.status.Other`) }; + } + return { + id: string(item.id, `${label}.id`), + kind: string(item.kind, `${label}.kind`), + labels: strings(item.labels, `${label}.labels`), + priority: string(item.priority, `${label}.priority`), + queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), + queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), + readiness: optionalNullableString(item.readiness, `${label}.readiness`), + resource_key: optionalNullableString( + item.resource_key, + `${label}.resource_key`, + ), + slug: string(item.slug, `${label}.slug`), + status, + tags: array(item.tags, `${label}.tags`, parseWorkspaceTag), + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + workflow_state: parseTicketState( + item.workflow_state, + `${label}.workflow_state`, + ), + workflow_state_explicit: boolean( + item.workflow_state_explicit, + `${label}.workflow_state_explicit`, + ), + }; +} + function parseTicketListItem( value: unknown, label: string, ): TicketListItemSummary { const item = object(value, label); exact(item, [ + "closure_operation_key", + "current_closure", + "legacy_handoff", "id", "priority", "queued_at", @@ -196,18 +436,21 @@ function parseTicketListItem( "record_source", "resource_key", "state", + "tags", "title", "updated_at", "workspace_action_priority", ], label); return { + ...caseProjection(item, label), id: string(item.id, `${label}.id`), priority: string(item.priority, `${label}.priority`), queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), record_source: string(item.record_source, `${label}.record_source`), resource_key: string(item.resource_key, `${label}.resource_key`), - state: string(item.state, `${label}.state`), + state: parseTicketState(item.state, `${label}.state`), + tags: array(item.tags, `${label}.tags`, parseWorkspaceTag), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), workspace_action_priority: string( @@ -414,6 +657,130 @@ function parseTicketRoleAssignmentRecord( }; } +export function parseAssignmentTracking( + value: unknown, + label: string, +): TicketAssignmentTracking { + const item = object(value, label); + exact( + item, + ["status", "waiting_reason", "progress", "next_action", "result"], + label, + ); + const status = string(item.status, `${label}.status`); + if (status !== "waiting" && status !== "inprogress" && status !== "done") { + throw new Error(`${label}.status is invalid`); + } + return { + status, + waiting_reason: optionalNullableString( + item.waiting_reason, + `${label}.waiting_reason`, + ), + progress: optionalNullableString(item.progress, `${label}.progress`), + next_action: optionalNullableString( + item.next_action, + `${label}.next_action`, + ), + result: optionalNullableString(item.result, `${label}.result`), + }; +} + +function parseAssignmentRelease( + value: unknown, + label: string, +): TicketAssignmentRelease { + const item = object(value, label); + exact(item, [ + "action", + "event_id", + "operation_key", + "actor", + "at", + "reason", + "replacement_assignment_id", + "last_tracking", + ], label); + const action = string(item.action, `${label}.action`); + if (action !== "unassigned" && action !== "replaced") { + throw new Error(`${label}.action is invalid`); + } + return { + action, + event_id: string(item.event_id, `${label}.event_id`), + operation_key: optionalNullableString( + item.operation_key, + `${label}.operation_key`, + ), + actor: string(item.actor, `${label}.actor`), + at: string(item.at, `${label}.at`), + reason: optionalNullableString(item.reason, `${label}.reason`), + replacement_assignment_id: optionalNullableString( + item.replacement_assignment_id, + `${label}.replacement_assignment_id`, + ), + last_tracking: item.last_tracking === undefined + ? undefined + : item.last_tracking === null + ? null + : parseAssignmentTracking(item.last_tracking, `${label}.last_tracking`), + }; +} + +export function parseAssignmentTrackingProjection( + value: unknown, + label: string, +): TicketAssignmentTrackingProjection { + const item = object(value, label); + exact(item, [ + "workspace_id", + "ticket_id", + "assignment_id", + "role", + "principal", + "assigned_by", + "assigned_at", + "release", + "current", + "tracking", + "last_event_id", + "updated_at", + "migration_diagnostic", + ], label); + return { + workspace_id: string(item.workspace_id, `${label}.workspace_id`), + ticket_id: string(item.ticket_id, `${label}.ticket_id`), + assignment_id: string(item.assignment_id, `${label}.assignment_id`), + role: string(item.role, `${label}.role`), + principal: parseTicketAssignmentPrincipal( + item.principal, + `${label}.principal`, + ), + assigned_by: string(item.assigned_by, `${label}.assigned_by`), + assigned_at: string(item.assigned_at, `${label}.assigned_at`), + release: item.release === undefined + ? undefined + : item.release === null + ? null + : parseAssignmentRelease(item.release, `${label}.release`), + current: boolean(item.current, `${label}.current`), + tracking: item.tracking === undefined + ? undefined + : item.tracking === null + ? null + : parseAssignmentTracking(item.tracking, `${label}.tracking`), + last_event_id: optionalNullableString( + item.last_event_id, + `${label}.last_event_id`, + ), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + migration_diagnostic: optionalNullableString( + item.migration_diagnostic, + `${label}.migration_diagnostic`, + ), + }; +} + function parseActionEligibility( value: unknown, label: string, @@ -726,7 +1093,9 @@ export function parseTicketDetail(value: unknown): TicketDetail { "artifact_count", "artifacts", "assignment_diagnostics", + "assignment_tracking", "assignments", + "closure_history", "body", "body_truncated", "created_at", @@ -735,6 +1104,9 @@ export function parseTicketDetail(value: unknown): TicketDetail { "event_page", "events", "evidence", + "closure_operation_key", + "current_closure", + "legacy_handoff", "id", "implementation_reports", "content_digest", @@ -751,11 +1123,18 @@ export function parseTicketDetail(value: unknown): TicketDetail { "resource_key", "risk_flags", "state", + "tags", "targets", "title", "updated_at", ], label); return { + ...caseProjection(item, label), + closure_history: array( + item.closure_history, + `${label}.closure_history`, + parseCaseHistory, + ), action_eligibility: parseActionEligibility( item.action_eligibility, `${label}.action_eligibility`, @@ -766,6 +1145,11 @@ export function parseTicketDetail(value: unknown): TicketDetail { item.assignment_diagnostics, `${label}.assignment_diagnostics`, ), + assignment_tracking: array( + item.assignment_tracking, + `${label}.assignment_tracking`, + parseAssignmentTrackingProjection, + ), assignments: array( item.assignments, `${label}.assignments`, @@ -820,7 +1204,8 @@ export function parseTicketDetail(value: unknown): TicketDetail { resolution: optionalNullableString(item.resolution, `${label}.resolution`), resource_key: string(item.resource_key, `${label}.resource_key`), risk_flags: strings(item.risk_flags, `${label}.risk_flags`), - state: string(item.state, `${label}.state`), + state: parseTicketState(item.state, `${label}.state`), + tags: array(item.tags, `${label}.tags`, parseWorkspaceTag), targets: array(item.targets, `${label}.targets`, parseTicketTarget), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), @@ -1370,3 +1755,152 @@ export function parseMergeRequestDetailResponse( workspace_id: string(item.workspace_id, `${label}.workspace_id`), }; } + +export function parseTicketQueryResponse(value: unknown): TicketQueryResponse { + const label = "Ticket query response"; + const page = object(value, label); + exact(page, ["items", "page", "record_authority"], label); + return { + page: parseQueryPage(page.page, `${label}.page`), + record_authority: string( + page.record_authority, + `${label}.record_authority`, + ), + items: array( + page.items, + `${label}.items`, + (value, label): TicketQueryItem => { + const item = object(value, label); + exact(item, [ + "assignment_tracking", + "blocker_count", + "closure_operation_key", + "content_digest", + "created_at", + "current_closure", + "current_worker", + "evidence", + "id", + "legacy_handoff", + "linked_objective_ids", + "linked_objective_keys", + "matched_fields", + "matching_event", + "merge_request", + "merge_requests", + "priority", + "readiness", + "relation_count", + "resource_key", + "snippet", + "state", + "tags", + "title", + "unresolved_blocker_count", + "unresolved_review_count", + "updated_at", + "workspace_action_priority", + ], label); + return { + id: string(item.id, `${label}.id`), + resource_key: string(item.resource_key, `${label}.resource_key`), + title: string(item.title, `${label}.title`), + state: parseTicketState(item.state, `${label}.state`), + priority: string(item.priority, `${label}.priority`), + assignment_tracking: array( + item.assignment_tracking, + `${label}.assignment_tracking`, + parseAssignmentTrackingProjection, + ), + tags: array(item.tags, `${label}.tags`, parseWorkspaceTag), + blocker_count: integer(item.blocker_count, `${label}.blocker_count`), + unresolved_blocker_count: integer( + item.unresolved_blocker_count, + `${label}.unresolved_blocker_count`, + ), + unresolved_review_count: integer( + item.unresolved_review_count, + `${label}.unresolved_review_count`, + ), + relation_count: integer( + item.relation_count, + `${label}.relation_count`, + ), + content_digest: string( + item.content_digest, + `${label}.content_digest`, + ), + created_at: optionalNullableString( + item.created_at, + `${label}.created_at`, + ), + updated_at: optionalNullableString( + item.updated_at, + `${label}.updated_at`, + ), + closure_operation_key: optionalNullableString( + item.closure_operation_key, + `${label}.closure_operation_key`, + ), + current_closure: item.current_closure == null + ? item.current_closure as null | undefined + : parseClosure(item.current_closure, `${label}.current_closure`), + current_worker: item.current_worker == null + ? item.current_worker as null | undefined + : parseTicketAssignmentSummary( + item.current_worker, + `${label}.current_worker`, + ), + evidence: parseTicketEvidenceSummary( + item.evidence, + `${label}.evidence`, + ), + legacy_handoff: item.legacy_handoff == null + ? item.legacy_handoff as null | undefined + : parseLegacyHandoff( + item.legacy_handoff, + `${label}.legacy_handoff`, + ), + linked_objective_ids: strings( + item.linked_objective_ids, + `${label}.linked_objective_ids`, + ), + linked_objective_keys: strings( + item.linked_objective_keys, + `${label}.linked_objective_keys`, + ), + matched_fields: strings( + item.matched_fields, + `${label}.matched_fields`, + ), + matching_event: item.matching_event == null + ? item.matching_event as null | undefined + : parseTicketEvidenceEvent( + item.matching_event, + `${label}.matching_event`, + ), + merge_request: item.merge_request == null + ? item.merge_request as null | undefined + : parseTicketMergeRequestSummary( + item.merge_request, + `${label}.merge_request`, + ), + merge_requests: array( + item.merge_requests, + `${label}.merge_requests`, + parseTicketMergeRequestSummary, + ), + readiness: optionalNullableString( + item.readiness, + `${label}.readiness`, + ), + snippet: optionalNullableString(item.snippet, `${label}.snippet`), + workspace_action_priority: string( + item.workspace_action_priority, + `${label}.workspace_action_priority`, + ), + }; + }, + ), + }; +} diff --git a/web/workspace/src/lib/workspace/api/ticket-case.test.ts b/web/workspace/src/lib/workspace/api/ticket-case.test.ts new file mode 100644 index 000000000..c1d25105b --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-case.test.ts @@ -0,0 +1,121 @@ +declare const Deno: { + test(name: string, fn: () => Promise<void> | void): void; +}; +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, received ${ + JSON.stringify(actual) + }`, + ); + } +} +async function assertRejects(operation: () => Promise<unknown>): Promise<void> { + try { + await operation(); + } catch (error) { + if (error instanceof Error && error.message.includes("409")) return; + throw error; + } + throw new Error("Expected stale CAS rejection"); +} +import type { TicketCaseAction } from "#lib/generated/ticket-api.ts"; +import { ticketCaseRequest, updateTicketCase } from "./ticket-case.ts"; + +const observed = { + content_digest: "a".repeat(64), + state: "open", + closure_operation_key: "last-correction", +} as const; +const summary = { + id: "ticket-1", + resource_key: "T-1", + slug: "case", + title: "Case", + kind: "task", + labels: [], + tags: [{ + workspace_id: "workspace one", + id: "tag-closed", + name: "closed", + last_operation_key: "create-tag", + deleted: false, + }], + priority: "P2", + status: "Closed", + workflow_state: "closed", + workflow_state_explicit: true, +}; + +Deno.test("case actions carry content/state/last-closure CAS without browser author or unrelated lifecycle fields", async () => { + const actions: TicketCaseAction[] = [ + ...["completed", "not_planned", "duplicate", "unknown", { + other: "Deferred", + }].map((reason) => ({ action: "close", reason } as TicketCaseAction)), + { action: "reopen" }, + { action: "correct_closure", reason: "unknown" }, + ]; + const original = globalThis.fetch; + try { + for (const action of actions) { + const request = ticketCaseRequest( + observed, + action, + " Case judgment ", + "operation-1", + ); + assertEquals(request, { + expected_content_digest: observed.content_digest, + expected_state: "open", + expected_closure_operation_key: "last-correction", + operation_key: "operation-1", + action, + explanation: "Case judgment", + references: [], + }); + globalThis.fetch = (input, init) => { + assertEquals(input, "/workspaces/workspace%20one/tickets/T%2F1/case"); + const options = init as { method?: string; body?: string }; + assertEquals(options.method, "POST"); + assertEquals(JSON.parse(String(options.body)), request); + return Promise.resolve(Response.json(summary)); + }; + const result = await updateTicketCase("workspace one", "T/1", request); + assertEquals(result.workflow_state, "closed"); + assertEquals(result.tags, summary.tags); + } + } finally { + globalThis.fetch = original; + } + assertEquals( + ticketCaseRequest( + { ...observed, closure_operation_key: null }, + { action: "close", reason: "unknown" }, + "Close", + "first", + ).expected_closure_operation_key, + null, + ); +}); + +Deno.test("case API propagates stale CAS failure rather than reporting success", async () => { + const original = globalThis.fetch; + globalThis.fetch = () => + Promise.resolve(Response.json({ message: "stale case" }, { status: 409 })); + try { + await assertRejects(() => + updateTicketCase( + "w", + "T-1", + ticketCaseRequest( + observed, + { action: "reopen" }, + "Reopen", + "operation-1", + ), + ) + ); + } finally { + globalThis.fetch = original; + } +}); diff --git a/web/workspace/src/lib/workspace/api/ticket-case.ts b/web/workspace/src/lib/workspace/api/ticket-case.ts new file mode 100644 index 000000000..d8030d3d8 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-case.ts @@ -0,0 +1,47 @@ +import type { + TicketCaseAction, + TicketCaseUpdateRequest, + TicketDetail, + TicketSummary, +} from "#lib/generated/ticket-api.ts"; +import { workspaceApiJsonWithBody } from "./http.ts"; +import { + parseTicketSummary, + TICKET_BROWSER_API_MAX_RESPONSE_BYTES, +} from "./ticket-browser.ts"; + +export function ticketCaseRequest( + ticket: Pick< + TicketDetail, + "content_digest" | "state" | "closure_operation_key" + >, + action: TicketCaseAction, + explanation: string, + operationKey: string, +): TicketCaseUpdateRequest { + return { + expected_content_digest: ticket.content_digest, + expected_state: ticket.state, + // The last judgment key survives reopening; do not derive it from current_closure. + expected_closure_operation_key: ticket.closure_operation_key ?? null, + operation_key: operationKey, + action, + explanation: explanation.trim(), + references: [], + }; +} + +export function updateTicketCase( + workspaceId: string, + ticketId: string, + request: TicketCaseUpdateRequest, +): Promise<TicketSummary> { + return workspaceApiJsonWithBody( + `/workspaces/${encodeURIComponent(workspaceId)}/tickets/${ + encodeURIComponent(ticketId) + }/case`, + { method: "POST", body: JSON.stringify(request) }, + parseTicketSummary, + TICKET_BROWSER_API_MAX_RESPONSE_BYTES, + ); +} diff --git a/web/workspace/src/lib/workspace/api/ticket-operations.test.ts b/web/workspace/src/lib/workspace/api/ticket-operations.test.ts new file mode 100644 index 000000000..a04b9aaac --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-operations.test.ts @@ -0,0 +1,275 @@ +declare const Deno: { + test(name: string, fn: () => void | Promise<void>): void; +}; +function ordered(value: unknown): unknown { + return Array.isArray(value) + ? value.map(ordered) + : value && typeof value === "object" + ? Object.fromEntries( + Object.entries(value).sort(([a], [b]) => a.localeCompare(b)).map(( + [key, item], + ) => [key, ordered(item)]), + ) + : value; +} +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(ordered(actual)) !== JSON.stringify(ordered(expected))) { + throw new Error( + `Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`, + ); + } +} +function assertThrows(operation: () => unknown): void { + try { + operation(); + } catch { + return; + } + throw new Error("Expected rejection"); +} +function assertStringIncludes(actual: string, expected: string): void { + if (!actual.includes(expected)) { + throw new Error(`${actual} does not include ${expected}`); + } +} +import type { + TicketAssignmentTrackingProjection, + TicketDetail, +} from "#lib/generated/ticket-api.ts"; +import { + answerOperation, + assignmentOperation, + observeOperation, + operationError, + parseAnswerReceipt, + parseAssignmentDiagnostics, + parseAssignmentsPage, + parseTrackingHistory, +} from "./ticket-operations.ts"; + +const current: TicketAssignmentTrackingProjection = { + workspace_id: "team one", + ticket_id: "ticket/one", + assignment_id: "assignment-1", + role: "orchestrator", + current: true, + principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + assigned_by: "account/user", + assigned_at: "2026-01-01T00:00:00Z", + tracking: { + status: "waiting", + waiting_reason: "User decision needed", + next_action: "Read answer", + }, + last_event_id: "event-1", + updated_at: "2026-01-02T00:00:00Z", +}; +function observed(): Pick<TicketDetail, "assignments"> { + return { + assignments: [{ + assignment_id: current.assignment_id, + role: "orchestrator", + principal: current.principal, + assigned_at: current.assigned_at, + assigned_by: current.assigned_by, + }], + }; +} +Deno.test("assignment retry retains the exact observed pointer, operation key and payload after new data arrives", () => { + const old = observed(); + const operation = assignmentOperation( + "team one", + "ticket/one", + old, + "operation-1", + ); + old.assignments[0].assignment_id = "replacement"; + assertEquals(JSON.parse(operation.body!), { + operation_id: "operation-1", + expected_assignment_id: "assignment-1", + principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + }); + assertEquals( + operation.path, + "/api/w/team%20one/tickets/ticket%2Fone/assignments/orchestrator", + ); + assertEquals(Object.isFrozen(operation), true); + const fresh = assignmentOperation( + "team one", + "ticket/one", + old, + "operation-2", + ); + assertEquals(JSON.parse(fresh.body!).expected_assignment_id, "replacement"); + const clear = assignmentOperation( + "team one", + "ticket/one", + old, + "release", + true, + ); + assertStringIncludes( + clear.path, + "operation_id=release&assignment_id=replacement", + ); + assertEquals(clear.method, "DELETE"); + assertThrows(() => + assignmentOperation("team", "ticket", { assignments: [] }, "clear", true) + ); +}); +Deno.test("waiting answers carry the same assignment and observed event without status or Assign mutation", () => { + const operation = answerOperation( + "team one", + "ticket/one", + current, + "My answer", + "answer-key", + ); + assertEquals(JSON.parse(operation.body!), { + assignment_id: "assignment-1", + expected_last_event_id: "event-1", + body: "My answer", + operation_key: "answer-key", + }); + assertEquals( + operation.path, + "/api/w/team%20one/tickets/ticket%2Fone/assignment-answer", + ); + for ( + const unavailable of [{ ...current, current: false }, { + ...current, + last_event_id: null, + }, { ...current, tracking: { status: "done" as const } }] + ) { + assertThrows(() => + answerOperation("team", "ticket", unavailable, "answer", "new-op") + ); + } +}); +Deno.test("operation snapshots are serialized once rather than silently rebased for exact retry", () => { + const body = { + operation_key: "saved", + tag_ids: ["stable-id"], + expected_tag_ids: [] as string[], + }; + const operation = observeOperation("/tags", "PUT", body); + body.tag_ids[0] = "renamed-id"; + assertEquals(JSON.parse(operation.body!).tag_ids, ["stable-id"]); +}); +Deno.test("delivery diagnostics keep saved notification, acceptance uncertainty and actual processing separate", () => { + const wire = { + assignment_id: "assignment-1", + current: true, + input_origin_known: true, + actionable: true, + inputs: [{ + input_event_id: "input-1", + delivery_id: "delivery-1", + notification: "request_persisted" as const, + acceptance_outcome_unknown: true, + }], + starts: [{ + operation_id: "start-1", + claim_state: "pending", + assignment_id: null, + runtime_id: null, + worker_id: null, + actual_processing_unknown: true, + }], + next_input_event_id: null, + next_start_operation_id: null, + }; + assertEquals(parseAssignmentDiagnostics(wire), wire); + assertThrows(() => + parseAssignmentDiagnostics({ ...wire, rendered_content: "must not leak" }) + ); + assertThrows(() => + parseAssignmentDiagnostics({ + ...wire, + inputs: [{ ...wire.inputs[0], notification: "done" }], + }) + ); + assertThrows(() => + parseAssignmentDiagnostics({ + ...wire, + inputs: Array(101).fill(wire.inputs[0]), + }) + ); +}); +Deno.test("retained assignment page preserves result and release rather than rewriting handling to done", () => { + const row = { + ...current, + current: false, + release: { + action: "replaced", + event_id: "release-event", + actor: "account/user", + at: current.assigned_at, + replacement_assignment_id: "assignment-2", + last_tracking: current.tracking, + }, + }; + const page = parseAssignmentsPage({ + items: [row], + next_assignment_id: "assignment-1", + }); + assertEquals(page.items[0].tracking?.status, "waiting"); + assertEquals(page.items[0].release?.action, "replaced"); + assertThrows(() => + parseAssignmentsPage({ + items: Array(101).fill(row), + next_assignment_id: null, + }) + ); + assertThrows(() => + parseAssignmentsPage({ + items: [row], + next_assignment_id: null, + guessed_status: "done", + }) + ); + assertThrows(() => parseTrackingHistory({ items: [], next_event_id: 1 })); +}); +Deno.test("answer receipt rejects unsafe event indices and extra inferred result fields", () => { + assertEquals( + parseAnswerReceipt({ + assignment_id: "a", + input_event_id: "event:1", + event_index: 1, + }), + { assignment_id: "a", input_event_id: "event:1", event_index: 1 }, + ); + assertThrows(() => + parseAnswerReceipt({ + assignment_id: "a", + input_event_id: "event:1", + event_index: 2 ** 54, + }) + ); + assertThrows(() => + parseAnswerReceipt({ + assignment_id: "a", + input_event_id: "event:1", + event_index: 1, + status: "inprogress", + }) + ); +}); +Deno.test("conflict, permission, unavailable and unknown errors never claim successful new delegation", () => { + assertStringIncludes( + operationError(new Error("request failed (409)")), + "Stale observation", + ); + assertStringIncludes( + operationError(new Error("request failed (403)")), + "Permission denied", + ); + assertStringIncludes( + operationError(new Error("request failed (404)")), + "unavailable", + ); + assertStringIncludes( + operationError(new Error("network response lost")), + "Outcome unknown", + ); +}); diff --git a/web/workspace/src/lib/workspace/api/ticket-operations.ts b/web/workspace/src/lib/workspace/api/ticket-operations.ts new file mode 100644 index 000000000..990353a11 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-operations.ts @@ -0,0 +1,293 @@ +import type { + SetTicketRoleAssignmentRequest, + TicketAssignmentAnswer, + TicketAssignmentAnswerReceipt, + TicketAssignmentDiagnosticsPage, + TicketAssignmentsPage, + TicketAssignmentTrackingHistoryPage, + TicketAssignmentTrackingProjection, + TicketDetail, + WorkspaceTag, +} from "#lib/generated/ticket-api.ts"; +import { workspaceApiPath } from "./http.ts"; +import { + parseAssignmentTracking, + parseAssignmentTrackingProjection, + parseWorkspaceTag, +} from "./ticket-browser.ts"; + +export function parseTags(value: unknown): WorkspaceTag[] { + if (!Array.isArray(value) || value.length > 1000) { + throw new Error("Tag list is invalid or unbounded"); + } + return value.map((tag, index) => parseWorkspaceTag(tag, `Tags[${index}]`)); +} +export function parseTracking( + value: unknown, +): TicketAssignmentTrackingProjection[] { + if (!Array.isArray(value) || value.length > 1000) { + throw new Error("Assignment list is invalid or unbounded"); + } + return value.map((item, index) => + parseAssignmentTrackingProjection(item, `Assignments[${index}]`) + ); +} +export function parseTrackingHistory( + value: unknown, +): TicketAssignmentTrackingHistoryPage { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("History must be an object"); + } + const page = value as Record<string, unknown>; + if ( + Object.keys(page).some((key) => + !["items", "next_event_id"].includes(key) + ) || !Array.isArray(page.items) || page.items.length > 100 + ) throw new Error("History page is invalid"); + const items = page.items.map((value, index) => { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("History entry must be an object"); + } + const item = value as Record<string, unknown>; + if ( + Object.keys(item).some((key) => + !["at", "event_id", "operation_key", "source_context", "tracking"] + .includes(key) + ) + ) throw new Error("Unexpected history field"); + for (const key of ["at", "event_id", "operation_key"]) { + if ( + typeof item[key] !== "string" || (item[key] as string).length > 1024 + ) throw new Error(`History ${key} is invalid`); + } + return { + at: item.at as string, + event_id: item.event_id as string, + operation_key: item.operation_key as string, + source_context: item.source_context, + tracking: parseAssignmentTracking(item.tracking, `History[${index}]`), + }; + }); + if ( + page.next_event_id != null && + (typeof page.next_event_id !== "string" || page.next_event_id.length > 1024) + ) throw new Error("History cursor is invalid"); + return { + items, + next_event_id: page.next_event_id as string | null | undefined, + }; +} +export function trackingPath(workspaceId: string, ticketId: string): string { + return `/workspaces/${encodeURIComponent(workspaceId)}/tickets/${ + encodeURIComponent(ticketId) + }/assignment-tracking`; +} +export type ObservedOperation = Readonly< + { path: string; method: "POST" | "PUT" | "DELETE"; body?: string } +>; +// Keep the exact serialized input and comparison subject for explicit retry. A new +// observation must never silently rebase an ambiguous operation onto a new assignment. +export function observeOperation( + path: string, + method: ObservedOperation["method"], + body?: unknown, +): ObservedOperation { + return Object.freeze({ + path, + method, + ...(body === undefined ? {} : { body: JSON.stringify(body) }), + }); +} +export function assignmentOperation( + workspaceId: string, + ticketId: string, + observed: Pick<TicketDetail, "assignments">, + operationId: string, + clear = false, + principal: SetTicketRoleAssignmentRequest["principal"] = { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, +): ObservedOperation { + const current = observed.assignments.find((row) => + row.role === "orchestrator" + ); + const path = workspaceApiPath( + workspaceId, + `/tickets/${encodeURIComponent(ticketId)}/assignments/orchestrator`, + ); + if (clear) { + if (!current) throw new Error("No observed current assignment to release"); + return observeOperation( + `${path}?${new URLSearchParams({ + operation_id: operationId, + assignment_id: current.assignment_id, + })}`, + "DELETE", + ); + } + const request: SetTicketRoleAssignmentRequest = { + operation_id: operationId, + expected_assignment_id: current?.assignment_id ?? null, + principal, + }; + return observeOperation(path, "PUT", request); +} +export function operationError(error: unknown): string { + const text = error instanceof Error ? error.message : String(error); + if (/\(409\)/.test(text)) { + return `Stale observation / conflict. Refresh and reconcile; do not overwrite or retry as a new operation. ${text}`; + } + if (/\(401\)|\(403\)/.test(text)) { + return `Permission denied. No access was granted. ${text}`; + } + if (/\(404\)/.test(text)) { + return `Resource or assignment unavailable. ${text}`; + } + if (/\(400\)|\(422\)/.test(text)) return `Input rejected. ${text}`; + return `Outcome unknown. This may already be saved; retain the same operation key for exact retry. ${text}`; +} + +function record(value: unknown, keys: string[]): Record<string, unknown> { + if (!value || typeof value !== "object" || Array.isArray(value)) { + throw new Error("Expected API object"); + } + const item = value as Record<string, unknown>; + if (Object.keys(item).some((key) => !keys.includes(key))) { + throw new Error("Unexpected API field"); + } + return item; +} +function text(value: unknown): string { + if (typeof value !== "string" || value.length > 16384) { + throw new Error("Expected bounded string"); + } + return value; +} +function optionalText(value: unknown): string | null | undefined { + return value == null ? value as null | undefined : text(value); +} +function flag(value: unknown): boolean { + if (typeof value !== "boolean") throw new Error("Expected boolean"); + return value; +} +function pageItems<T>(value: unknown, parse: (value: unknown) => T): T[] { + if (!Array.isArray(value) || value.length > 100) { + throw new Error("Expected bounded page"); + } + return value.map(parse); +} +export function parseAssignmentsPage(value: unknown): TicketAssignmentsPage { + const page = record(value, ["items", "next_assignment_id"]); + return { + items: pageItems( + page.items, + (item) => parseAssignmentTrackingProjection(item, "Assignment page"), + ), + next_assignment_id: optionalText(page.next_assignment_id), + }; +} +export function parseAnswerReceipt( + value: unknown, +): TicketAssignmentAnswerReceipt { + const item = record(value, [ + "assignment_id", + "input_event_id", + "event_index", + ]); + if ( + !Number.isSafeInteger(item.event_index) || (item.event_index as number) < 0 + ) throw new Error("Answer event index invalid"); + return { + assignment_id: text(item.assignment_id), + input_event_id: text(item.input_event_id), + event_index: item.event_index as number, + }; +} +export function answerOperation( + workspaceId: string, + ticketId: string, + observed: TicketAssignmentTrackingProjection, + body: string, + operationKey: string, +): ObservedOperation { + if ( + !observed.current || !observed.last_event_id || !observed.tracking || + observed.tracking.status === "done" + ) throw new Error("Read actionable current assignment before answering"); + const request: TicketAssignmentAnswer = { + assignment_id: observed.assignment_id, + expected_last_event_id: observed.last_event_id, + body, + operation_key: operationKey, + }; + return observeOperation( + workspaceApiPath( + workspaceId, + `/tickets/${encodeURIComponent(ticketId)}/assignment-answer`, + ), + "POST", + request, + ); +} +export function parseAssignmentDiagnostics( + value: unknown, +): TicketAssignmentDiagnosticsPage { + const page = record(value, [ + "assignment_id", + "current", + "input_origin_known", + "actionable", + "inputs", + "next_input_event_id", + "starts", + "next_start_operation_id", + ]); + return { + assignment_id: text(page.assignment_id), + current: flag(page.current), + input_origin_known: flag(page.input_origin_known), + actionable: flag(page.actionable), + next_input_event_id: optionalText(page.next_input_event_id), + next_start_operation_id: optionalText(page.next_start_operation_id), + inputs: pageItems(page.inputs, (value) => { + const item = record(value, [ + "input_event_id", + "delivery_id", + "notification", + "acceptance_outcome_unknown", + ]); + if ( + !["not_reserved", "reserved", "request_persisted", "runtime_accepted"] + .includes(item.notification as string) + ) throw new Error("Unexpected notification state"); + return { + input_event_id: text(item.input_event_id), + delivery_id: optionalText(item.delivery_id), + notification: item + .notification as TicketAssignmentDiagnosticsPage["inputs"][number][ + "notification" + ], + acceptance_outcome_unknown: flag(item.acceptance_outcome_unknown), + }; + }), + starts: pageItems(page.starts, (value) => { + const item = record(value, [ + "operation_id", + "claim_state", + "assignment_id", + "runtime_id", + "worker_id", + "actual_processing_unknown", + ]); + return { + operation_id: text(item.operation_id), + claim_state: text(item.claim_state), + assignment_id: optionalText(item.assignment_id), + runtime_id: optionalText(item.runtime_id), + worker_id: optionalText(item.worker_id), + actual_processing_unknown: flag(item.actual_processing_unknown), + }; + }), + }; +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index d557759c4..ba24943a7 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -392,24 +392,20 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as "Tickets, Objectives, and Merge Requests should each be a single primary sidebar link", ); assert( - ticketsLoad.includes("loadLane(fetch, workspaceId") && - ticketsLoad.includes('limit: "30"') && - ticketsLoad.includes('states: states.join(",")') && - ticketsLoad.includes("/tickets?${search}") && - !ticketsLoad.includes("/tickets/query") && + ticketsLoad.includes("ticketQueryRequest(filters, [state])") && + ticketsLoad.includes("/tickets/query") && + ticketsLoad.includes('method: "POST"') && ticketsPage.includes('class="ticket-kanban"') && ticketsPage.includes("laneState") && - ticketsPage.includes("loadMore(lane.id)") && - ticketsPage.includes("handleLaneScroll(event, lane.id)"), - "Tickets list should fetch lightweight paginated summaries for each Kanban lane", + ticketsPage.includes("loadMore(lane.id)"), + "Tickets list must apply shared server query filters before paging case lanes", ); assert( - ticketPanelModel.includes('label: "Ready + Planning"') && - ticketPanelModel.includes('label: "In progress + Queued"') && - ticketPanelModel.includes('label: "Done + Closed"') && + ticketPanelModel.includes('label: "Open"') && + ticketPanelModel.includes('label: "Closed"') && ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") && ticketPanelModel.includes("nextTicketLaneVisibleCount"), - "Ticket Kanban should combine related states into independent 30-item display windows", + "Ticket Kanban should separate cases into independent 30-item display windows", ); assert( generatedTicketApi.includes( @@ -428,15 +424,17 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as ); assert( ticketDetailLoad.includes("/repositories") && - ticketDetailPage.includes('mutate("state", "/state"') && - ticketDetailPage.includes("async function queueTicket") && + ticketDetailPage.includes("ticketCaseRequest(ticket, action") && + ticketDetailPage.includes("TicketHandling") && ticketDetailPage.includes("const path = ticketPath") && - ticketDetailPage.includes("`${path}/queue`") && + !ticketDetailPage.includes("queueTicket") && + !ticketDetailPage.includes("markReady") && !ticketDetailPage.includes("/merge-request/merge") && ticketDetailPage.includes("mergeRequest.selector_from") && ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") && ticketDetailPage.includes("mergeRequestPagePath") && - ticketDetailPage.includes('mutate("close", "/close"') && + ticketDetailPage.includes("runCaseDecision") && + ticketDetailPage.includes("TicketTags") && ticketDetailPage.includes("mutateAssignment") && ticketDetailPage.includes("can_start_manual_worker") && ticketDetailPage.includes("ticket.relations.outgoing"), diff --git a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts index 6e5327c01..1818609cb 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -10,29 +10,36 @@ export const fixturePage = (returned: number, more = false) => ({ }); const updated = (hour: number) => `2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`; -export function fixtureTicket(key: string, state: string, hour = 10) { +export function fixtureTicket( + key: string, + state: "open" | "closed", + hour = 10, +) { return { id: `internal-${key}`, resource_key: key, title: key === "T-101" ? "Restore queued Worker submissions" : `${state} ticket ${key}`, + closure_operation_key: null, + current_closure: null, + legacy_handoff: null, state, + tags: [], priority: "normal", queued_at: null, queued_by: null, updated_at: updated(hour), record_source: "fixture", - workspace_action_priority: state === "inprogress" || state === "queued" - ? "active_work" - : "background", + workspace_action_priority: state === "open" ? "active_work" : "background", }; } export function fixtureDetail(key = "T-101") { - const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued"); + const summary = fixtureTicket(key, "open"); const { workspace_action_priority: _, ...ticket } = summary; return { ...ticket, + closure_history: [], content_digest: "a".repeat(64), created_at: updated(1), readiness: null, @@ -42,6 +49,7 @@ export function fixtureDetail(key = "T-101") { artifacts: [], artifact_count: 0, assignments: [], + assignment_tracking: [], assignment_diagnostics: [], events: [], event_count: 0, @@ -87,7 +95,7 @@ export function fixtureDetail(key = "T-101") { blockers: key === "T-101" ? [{ blocking_resource_key: "T-99", - blocking_state: "ready", + blocking_state: "open", blocking_ticket: "internal-T-99", note: null, reason_kind: "dependency", @@ -108,11 +116,13 @@ export function dashboardFixture( const state = search.get("states")!; const items = empty ? [] - : state === "inprogress,queued" - ? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")] - : state === "done" - ? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)] - : [fixtureTicket("T-105", "closed", 14)]; + : state === "open" + ? [fixtureTicket("T-101", "open"), fixtureTicket("T-102", "open")] + : [ + fixtureTicket("T-105", "closed", 14), + fixtureTicket("T-103", "closed", 12), + fixtureTicket("T-104", "closed", 7), + ]; if (long && items[0]) { items[0].title = "長いTicket名 — ".repeat(8) + "preserve-the-full-title-without-horizontal-overflow"; diff --git a/web/workspace/src/lib/workspace/home/dashboard.ts b/web/workspace/src/lib/workspace/home/dashboard.ts index 12a0c1a17..5fcf7f168 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.ts @@ -35,9 +35,7 @@ export type Dashboard = { }; const states: Record<string, string> = { - inprogress: "In progress", - queued: "Queued", - done: "Done", + open: "Open", closed: "Closed", active: "Active", completed: "Completed", @@ -90,8 +88,8 @@ export function recentRows(rows: DashboardRow[]): DashboardRow[] { .slice(0, HOME_LIMIT); } -// These are bounded, read-only browser endpoints. QueryTicket/QueryObjective are -// tool-only capabilities and must not be approximated by guessed HTTP routes. +// Homeは件数を絞ったread-onlyの一覧を使う。Ticketの検索・絞込みはTickets側の +// 公開query APIが担い、Homeで取得した一部分をWorkspace全体の検索結果にしない。 export function loadDashboard( fetchFn: typeof fetch, workspaceId: string, @@ -113,8 +111,7 @@ export function loadDashboard( })}`, parseTicketListResponse, ); - const activeResult = tickets("inprogress,queued"); - const doneResult = tickets("done"); + const activeResult = tickets("open"); const closedResult = tickets("closed"); const objectivesResult = get( `/objectives?limit=${HOME_LIMIT}`, @@ -179,7 +176,7 @@ export function loadDashboard( } const current = detail.data; // A concurrent transition can remove a Ticket from this snapshot's scope. - if (!["inprogress", "queued"].includes(current.state)) { + if (current.state !== "open") { return { changed: true }; } const blockers = current.relations.blockers; @@ -218,17 +215,14 @@ export function loadDashboard( : result.data.page.has_more || result.data.page.source_truncated ? `Showing ${ Math.min(result.data.items.length, HOME_LIMIT) - } active tickets, with in-progress work first.` + } open tickets.` : undefined, }; }), - recent: Promise.all([doneResult, closedResult, objectivesResult]).then(( - [done, closed, objectives], + recent: Promise.all([closedResult, objectivesResult]).then(( + [closed, objectives], ): DashboardFeed => ({ rows: recentRows([ - ...(done.data?.items ?? []).map((ticket) => - ticketRow(workspaceId, ticket) - ), ...(closed.data?.items ?? []).map((ticket) => ticketRow(workspaceId, ticket) ), @@ -243,21 +237,17 @@ export function loadDashboard( })), ]), errors: [ - ...(!done.data ? [failure("Done tickets", done.error)] : []), ...(!closed.data ? [failure("Closed tickets", closed.error)] : []), ...(!objectives.data ? [failure("Objectives", objectives.error)] : []), - ...([done.data, closed.data, objectives.data].some((data) => + ...([closed.data, objectives.data].some((data) => data?.invalid_records.length ) ? ["Some records could not be read."] : []), ], - notice: - [done.data, closed.data, objectives.data].some((data) => - data?.items.length - ) - ? "Up to 5 latest updates to done / closed tickets and Objectives." - : undefined, + notice: [closed.data, objectives.data].some((data) => data?.items.length) + ? "Up to 5 latest updates to closed tickets and Objectives." + : undefined, })), }; } diff --git a/web/workspace/src/lib/workspace/merge-request-status.test.ts b/web/workspace/src/lib/workspace/merge-request-status.test.ts index c02bac2ce..6ead21155 100644 --- a/web/workspace/src/lib/workspace/merge-request-status.test.ts +++ b/web/workspace/src/lib/workspace/merge-request-status.test.ts @@ -86,10 +86,10 @@ Deno.test("observed live source movement requires fresh review but an unchanged ); }); -Deno.test("done Ticket can retain immutable approval while current requirement evidence is incomplete", () => { +Deno.test("closed Ticket can retain immutable approval while current requirement evidence is incomplete", () => { const ticket = parseTicketDetail({ ...fixtureDetail(), - state: "done", + state: "closed", evidence: { ...fixtureDetail().evidence, has_merge_request: true, @@ -107,7 +107,7 @@ Deno.test("done Ticket can retain immutable approval while current requirement e source_ref_observation: { status: "not_required" }, }], }); - assertEquals(ticket.state, "done"); + assertEquals(ticket.state, "closed"); assertEquals(ticket.evidence.complete_for_integration, false); assertEquals( currentRequirementApprovalStatus(ticket.evidence), @@ -128,14 +128,14 @@ Deno.test("done Ticket can retain immutable approval while current requirement e currentRequirementApprovalStatus(recovered.evidence), "approved", ); - assertEquals(recovered.state, "done"); + assertEquals(recovered.state, "closed"); assertEquals( summarySourceReviewStatus(ticket.merge_requests[0]!), "Integration approval recorded for immutable merged source source-1.", ); }); -Deno.test("Ticket route separates current requirement evidence from immutable integration approval without cancelling done", async () => { +Deno.test("Ticket route separates current requirement evidence from immutable integration approval without changing case closure", async () => { const source = await Deno.readTextFile( new URL( "../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte", @@ -144,15 +144,15 @@ Deno.test("Ticket route separates current requirement evidence from immutable in ); for ( const required of [ - "Current requirement evidence", + "MR requirement evidence", "ticket.evidence.complete_for_integration", "Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}", "ticket.evidence.missing", "summarySourceReviewStatus(mergeRequest)", "Immutable integration evidence", "Integration evidence error:", - '{#if ticket.state === "done"}', - "Incomplete current requirement evidence does not cancel recorded completion.", + '{#if ticket.state === "closed"}', + "Incomplete MR evidence does not alter the recorded case judgment.", ] ) { if (!source.includes(required)) { diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index ac8821c80..cfc6046aa 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -131,7 +131,7 @@ .ticket-kanban { display: grid; align-items: stretch; - grid-template-columns: repeat(3, minmax(18rem, 1fr)); + grid-template-columns: repeat(2, minmax(18rem, 1fr)); gap: var(--space-3); overflow-x: auto; padding-bottom: var(--space-3); @@ -168,13 +168,10 @@ border-radius: 50%; background: var(--text-muted); } - .ticket-lane[data-state="ready-planning"] .ticket-state-dot { + .ticket-lane[data-state="open"] .ticket-state-dot { background: var(--success); } - .ticket-lane[data-state="inprogress-queued"] .ticket-state-dot { - background: var(--warning); - } - .ticket-lane[data-state="done-closed"] .ticket-state-dot { + .ticket-lane[data-state="closed"] .ticket-state-dot { background: var(--accent); } .ticket-lane-count { diff --git a/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte b/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte new file mode 100644 index 000000000..de6df2070 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/TicketHandling.svelte @@ -0,0 +1,219 @@ +<script lang="ts"> + import { onDestroy, onMount } from "svelte"; + import type { TicketDetail, TicketAssignmentTrackingProjection, TicketAssignmentTrackingHistoryPage, TicketAssignmentsPage, TicketAssignmentDiagnosticsPage, TicketAssignmentPrincipalSnapshot } from "#lib/generated/ticket-api.ts"; + import type { WorkspaceOrchestratorStatus } from "./ticket-panel.ts"; + import { workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath, formatDate } from "#lib/workspace/api/http.ts"; + import { parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES } from "#lib/workspace/api/ticket-browser.ts"; + import { assignmentOperation, answerOperation, parseAnswerReceipt, parseAssignmentsPage, parseAssignmentDiagnostics, operationError, parseTracking, parseTrackingHistory, trackingPath, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts"; + let { workspaceId, ticket, orchestrator, refresh }: { workspaceId: string; ticket: TicketDetail; orchestrator: WorkspaceOrchestratorStatus | null; refresh: () => Promise<void> } = $props(); + const current = $derived(ticket.assignment_tracking.find((row) => row.current && row.role === "orchestrator")); + let busy = $state(false); + let pending = $state<ObservedOperation | null>(null); + let error = $state<string | null>(null); + let savedNotice = $state<string | null>(null); + let pendingKind = $state<"assignment" | "answer">("assignment"); + let assignments = $state<TicketAssignmentsPage | null>(null); + let assignmentPageBusy = $state(false); + let diagnostic = $state<TicketAssignmentDiagnosticsPage | null>(null); + let diagnosticError = $state<string | null>(null); + let diagnosticBusy = $state(false); + let diagnosticRead = 0; + let answer = $state(""); + let principalKind = $state<"workspace_agent" | "user" | "worker">("workspace_agent"); + let accountId = $state(""); + let runtimeId = $state(""); + let workerId = $state(""); + const principalReady = $derived(principalKind === "workspace_agent" || (principalKind === "user" ? !!accountId.trim() : !!runtimeId.trim() && !!workerId.trim())); + function selectedPrincipal(): TicketAssignmentPrincipalSnapshot { + if (principalKind === "user") return { kind: "user", account_id: accountId.trim() }; + if (principalKind === "worker") return { kind: "worker", runtime_id: runtimeId.trim(), worker_id: workerId.trim() }; + return { kind: "workspace_agent", agent_key: "workspace-orchestrator" }; + } + let historyId = $state(""); + let retained = $state<TicketAssignmentTrackingProjection | null>(null); + let history = $state<TicketAssignmentTrackingHistoryPage | null>(null); + let historyError = $state<string | null>(null); + let historyBusy = $state(false); + let alive = true; + onDestroy(() => { alive = false; }); + async function run(operation: ObservedOperation, kind: "assignment" | "answer" = pendingKind) { + if (busy) return; + busy = true; error = null; pending = operation; pendingKind = kind; savedNotice = null; + try { + if (kind === "answer") { + const receipt = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseAnswerReceipt, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + if (!alive) return; + savedNotice = `Answer saved to assignment ${receipt.assignment_id}, input ${receipt.input_event_id}. Handling status is unchanged.`; + answer = ""; + } else { + const result = await workspaceApiJsonWithBody(operation.path, { method: operation.method, body: operation.body }, parseTicketRoleAssignmentMutationResponse, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + if (!alive) return; + savedNotice = `Assignment operation saved: ${result.assignment?.assignment_id ?? "released"}. Delivery and execution are separate.`; + } + pending = null; + await refresh(); + await readAssignments(); + if (current) await readDiagnostics(current.assignment_id); + } catch (cause) { if (alive) error = savedNotice ? `Operation saved; latest projection unavailable. ${operationError(cause)}` : operationError(cause); } + finally { busy = false; } + } + function delegate(clear = false) { + if (!clear && !principalReady) return; + void run(assignmentOperation(workspaceId, ticket.id, ticket, crypto.randomUUID(), clear, selectedPrincipal()), "assignment"); + } + async function reconcile() { + if (busy) return; + busy = true; + try { await refresh(); if (!alive) return; pending = null; error = null; savedNotice = null; await readAssignments(); if (current) await readDiagnostics(current.assignment_id); } + catch (cause) { if (alive) error = operationError(cause); } + finally { busy = false; } + } + async function readHistory(assignmentId: string, more = false) { + if (!assignmentId || historyBusy) return; + historyBusy = true; historyError = null; + const root = trackingPath(workspaceId, ticket.id); + try { + const rows = await workspaceApiJson(`${root}?${new URLSearchParams({ assignment_id: assignmentId })}`, parseTracking, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + retained = rows[0] ?? null; + const search = new URLSearchParams({ assignment_id: assignmentId, limit: "20" }); + if (more && history?.next_event_id) search.set("after_event_id", history.next_event_id); + const page = await workspaceApiJson(`${root}/history?${search}`, parseTrackingHistory, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + history = more && history ? { ...page, items: [...history.items, ...page.items] } : page; + historyId = assignmentId; + } catch (cause) { historyError = operationError(cause); } + finally { historyBusy = false; } + } + + async function readAssignments(more = false) { + if (assignmentPageBusy) return; + assignmentPageBusy = true; + try { + const query = new URLSearchParams({ limit: "20" }); + if (more && assignments?.next_assignment_id) query.set("after_assignment_id", assignments.next_assignment_id); + const page = await workspaceApiJson(trackingPath(workspaceId, ticket.id) + "/assignments?" + query, parseAssignmentsPage, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + if (!alive) return; + assignments = more && assignments ? { ...page, items: [...assignments.items, ...page.items] } : page; + } catch (cause) { historyError = operationError(cause); } + finally { assignmentPageBusy = false; } + } + async function readDiagnostics(id: string, more = false) { + const read = ++diagnosticRead; + diagnosticBusy = true; diagnosticError = null; + try { + const query = new URLSearchParams({ assignment_id: id, limit: "20" }); + if (more && diagnostic?.assignment_id === id) { + const inputCursor = diagnostic.next_input_event_id ?? diagnostic.inputs.at(-1)?.input_event_id; + const startCursor = diagnostic.next_start_operation_id ?? diagnostic.starts.at(-1)?.operation_id; + if (inputCursor) query.set("after_input_event_id", inputCursor); + if (startCursor) query.set("after_start_operation_id", startCursor); + } + const page = await workspaceApiJson(workspaceApiPath(workspaceId, "/tickets/" + encodeURIComponent(ticket.id) + "/assignment-diagnostics?") + query, parseAssignmentDiagnostics, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + if (!alive || read !== diagnosticRead) return; + diagnostic = more && diagnostic?.assignment_id === id ? { ...page, inputs: [...diagnostic.inputs, ...page.inputs], starts: [...diagnostic.starts, ...page.starts] } : page; + } catch (cause) { if (alive && read === diagnosticRead) diagnosticError = operationError(cause); } + finally { if (read === diagnosticRead) diagnosticBusy = false; } + } + function submitAnswer(event: SubmitEvent) { + event.preventDefault(); + if (!current || pending || !answer.trim()) return; + void run(answerOperation(workspaceId, ticket.id, current, answer, crypto.randomUUID()), "answer"); + } + onMount(() => { void readAssignments(); if (current) void readDiagnostics(current.assignment_id); }); +</script> +<section class="ticket-detail-section ticket-handling"> + <div class="ticket-section-heading"><h2>Orchestrator handling</h2><a href={`/w/${encodeURIComponent(workspaceId)}/orchestrator`}>All handling</a></div> + {#if current} + <p><strong>Self-reported handling: {current.tracking?.status ?? "unknown"}</strong></p> + <p>Principal: {current.principal.kind === "workspace_agent" ? current.principal.agent_key : current.principal.kind === "worker" ? `${current.principal.runtime_id}/${current.principal.worker_id}` : current.principal.account_id}</p> + <p>Assignment <code>{current.assignment_id}</code> · assigned by {current.assigned_by} · {formatDate(current.assigned_at)}</p> + {#if current.tracking} + <dl> + {#if current.tracking.waiting_reason}<dt>Waiting reason</dt><dd>{current.tracking.waiting_reason}</dd>{/if} + {#if current.tracking.progress}<dt>Progress</dt><dd>{current.tracking.progress}</dd>{/if} + {#if current.tracking.next_action}<dt>Next action</dt><dd>{current.tracking.next_action}</dd>{/if} + {#if current.tracking.result}<dt>Result of this handling</dt><dd>{current.tracking.result}</dd>{/if} + </dl> + {/if} + {#if current.updated_at}<p>Updated {formatDate(current.updated_at)}</p>{/if} + {#if current.migration_diagnostic}<p class="workspace-callout">{current.migration_diagnostic}</p>{/if} + {#if current.tracking?.status === "done"}<p>This handling was returned. Ticket {ticket.state} is unchanged.</p>{/if} + <button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(current.assignment_id)}>View handling history</button> + {:else}<p class="workspace-empty-copy">No current Orchestrator assignment.</p>{/if} + {#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if} + {#if savedNotice}<p role="status">{savedNotice}</p>{/if} + {#if pending} + <p>Unresolved operation. New delegation is disabled until reconciled.</p> + <button class="workspace-secondary-button" disabled={busy} onclick={() => pending && void run(pending)}>Retry exact operation</button> + <button class="workspace-secondary-button" disabled={busy} onclick={() => void reconcile()}>Refresh and reconcile assignment</button> + {/if} + {#if ticket.state === "closed" && ticket.action_eligibility.can_assign_orchestrator}<p class="workspace-empty-copy">A delegation can be saved, but this closed case does not admit new work. Reopening the case is a separate decision and does not itself reissue handling.</p>{/if} + {#if ticket.action_eligibility.can_assign_orchestrator} + <details class="assignment-principal"><summary>Choose responsibility principal</summary> + <fieldset class="ticket-control-form" disabled={busy || pending !== null}> + <label>Principal kind<select bind:value={principalKind}><option value="workspace_agent">Workspace Orchestrator agent</option><option value="user">User account</option><option value="worker">Worker principal</option></select></label> + {#if principalKind === "user"}<label>Account identity<input bind:value={accountId} maxlength="1024" /></label>{/if} + {#if principalKind === "worker"}<label>Runtime identity<input bind:value={runtimeId} maxlength="1024" /></label><label>Worker identity<input bind:value={workerId} maxlength="1024" /></label>{/if} + </fieldset> + <p>Backend validates the selected principal. Responsibility does not choose or start a concrete Worker.</p> + </details> + {/if} + <div class="handling-actions"> + {#if ticket.action_eligibility.can_assign_orchestrator} + {#if !current || current.tracking?.status === "done"} + <button class="workspace-primary-button" disabled={busy || pending !== null || !principalReady} onclick={() => delegate()}>{busy ? "Saving…" : current ? "Request again (new assignment)" : "Request Orchestrator"}</button> + {:else} + <details><summary>Replace this delegation</summary><p>A replacement starts a new assignment even for the same principal. The previous result and release history remain.</p><button class="workspace-secondary-button" disabled={busy || pending !== null || !principalReady} onclick={() => { if (confirm("Replace this delegation with a new assignment to the selected principal?")) delegate(); }}>Replace assignment</button></details> + {/if} + {/if} + {#if ticket.action_eligibility.can_unassign_orchestrator}<button class="workspace-danger-button" disabled={busy || pending !== null} onclick={() => { if (confirm("Release responsibility? This does not stop any running Worker.")) delegate(true); }}>Release assignment</button>{/if} + </div> + {#if ticket.state === "open" && current?.last_event_id && current.tracking && current.tracking.status !== "done"} + <form class="ticket-control-form ticket-answer" onsubmit={submitAnswer}> + <label>Answer / continue this handling<textarea bind:value={answer} rows="3" required disabled={busy || pending !== null}></textarea></label> + <p>Continues assignment <code>{current.assignment_id}</code>; not a new delegation. A stale assignment is rejected.</p> + <button class="workspace-secondary-button" disabled={busy || pending !== null || !answer.trim()}>Send answer to this assignment</button> + </form> + {/if} + <details class="ticket-assignment-history"><summary>Past assignment and release history</summary> + {#if assignmentPageBusy}<p role="status">Loading assignment history…</p>{/if} + {#each assignments?.items ?? [] as row (row.assignment_id)}<p><button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(row.assignment_id)}>{row.current ? "Current" : "Past"} · {row.tracking?.status ?? "unknown"} · {formatDate(row.assigned_at)}</button> <code>{row.assignment_id}</code>{#if row.release} · {row.release.action}{/if}{#if row.tracking?.result} · {row.tracking.result}{/if}</p>{/each} + {#if assignments?.next_assignment_id}<button class="workspace-secondary-button" disabled={assignmentPageBusy} onclick={() => void readAssignments(true)}>More assignments</button>{/if} + <form onsubmit={(event) => { event.preventDefault(); void readHistory(historyId); }}><label>Assignment identity<input bind:value={historyId} required /></label><button class="workspace-secondary-button" disabled={historyBusy}>Read assignment</button></form> + {#if historyError}<p role="alert" class="workspace-callout is-error">{historyError}</p>{/if} + {#if retained} + <p><code>{retained.assignment_id}</code> · {retained.current ? "current" : "past"} · last handling {retained.tracking?.status ?? "unknown"}</p> + {#if retained.tracking?.result}<p>Previous result: {retained.tracking.result}</p>{/if} + {#if retained.release}<p>{retained.release.action} by {retained.release.actor} · {formatDate(retained.release.at)}</p>{#if retained.release.replacement_assignment_id}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(retained!.release!.replacement_assignment_id!)}>Read replacement assignment</button>{/if}{/if} + {/if} + {#if history}{#each history.items as entry (entry.event_id)}<article><p>{entry.tracking.status} · {formatDate(entry.at)}</p>{#if entry.tracking.waiting_reason}<p>Waiting reason: {entry.tracking.waiting_reason}</p>{/if}{#if entry.tracking.progress}<p>Progress: {entry.tracking.progress}</p>{/if}{#if entry.tracking.next_action}<p>Next action: {entry.tracking.next_action}</p>{/if}{#if entry.tracking.result}<p>Result: {entry.tracking.result}</p>{/if}</article>{/each}{#if history.next_event_id}<button class="workspace-secondary-button" disabled={historyBusy} onclick={() => void readHistory(historyId, true)}>More handling history</button>{/if}{/if} + </details> +</section> +<section class="ticket-detail-section ticket-delivery"> + <div class="ticket-section-heading"><h2>Delivery and execution</h2></div> + <p>Assignment persistence: {current ? "Saved: " + current.assignment_id : "No current assignment"}</p> + {#if current}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => current && void readDiagnostics(current.assignment_id)}>Refresh delivery diagnostics</button>{/if} + {#if diagnosticError}<p class="workspace-callout is-error" role="alert">{diagnosticError}</p>{/if} + {#if diagnosticBusy}<p role="status">Loading delivery diagnostics…</p>{/if} + {#if diagnostic} + <p>Assignment <code>{diagnostic.assignment_id}</code> · {diagnostic.actionable ? "actionable input" : "retained history / not actionable"}</p> + {#if !diagnostic.input_origin_known}<p>Input origin unknown; not inferred from handling.</p>{/if} + {#each diagnostic.inputs as input (input.input_event_id)} + <article><p>Input <code>{input.input_event_id}</code></p><dl><dt>Notification</dt><dd>{input.notification.replaceAll("_", " ")}</dd><dt>Runtime acceptance</dt><dd>{input.notification === "runtime_accepted" ? "Accepted immutable notification" : input.acceptance_outcome_unknown ? "Outcome unknown — may already be accepted" : "No acceptance acknowledgement observed"}</dd></dl></article> + {:else}<p>No delivery input records in this page.</p>{/each} + <h3>Work start / admission evidence</h3> + {#each diagnostic.starts as start (start.operation_id)}<p><code>{start.operation_id}</code>: {start.claim_state} · {start.runtime_id ?? "runtime unknown"}/{start.worker_id ?? "worker unknown"}{#if start.actual_processing_unknown} · actual model processing not attested{/if}</p>{:else}<p>No work start records observed. This is not inferred from Worker state.</p>{/each} + {#if diagnostic.next_input_event_id || diagnostic.next_start_operation_id}<button class="workspace-secondary-button" disabled={diagnosticBusy} onclick={() => diagnostic && void readDiagnostics(diagnostic.assignment_id, true)}>More delivery / start records</button>{/if} + {:else}<p>Delivery / Runtime acceptance / work start not observed.</p>{/if} +</section> +<section class="ticket-detail-section ticket-orchestrator-worker"> + <div class="ticket-section-heading"><h2>Orchestrator Worker</h2></div> + {#if orchestrator?.worker}<p>{orchestrator.worker.runtime_id}/{orchestrator.worker.worker_id} · actual state: <strong>{orchestrator.worker.state}</strong> · {orchestrator.online ? "online" : "offline"}</p><a href={`/w/${encodeURIComponent(workspaceId)}/workers/${encodeURIComponent(orchestrator.worker.worker_id)}?runtimeId=${encodeURIComponent(orchestrator.worker.runtime_id)}`}>Open Worker</a>{:else}<p>Worker observation unavailable. This does not change handling status.</p>{/if} +</section> +<style> + .assignment-principal fieldset { min-width: 0; margin: var(--space-3, 0.75rem) 0 0; padding: 0; border: 0; } + dl { display: grid; grid-template-columns: minmax(7rem, 1fr) minmax(0, 3fr); gap: 0.5rem 1rem; } + dt { color: var(--text-muted); } dd { margin: 0; white-space: pre-wrap; overflow-wrap: anywhere; } + code { overflow-wrap: anywhere; } .handling-actions { display: flex; gap: 0.5rem; flex-wrap: wrap; margin-block: 1rem; } + article + article { margin-top: 1rem; } + @media (max-width: 540px) { dl { grid-template-columns: 1fr; } } +</style> diff --git a/web/workspace/src/lib/workspace/tickets/TicketTags.svelte b/web/workspace/src/lib/workspace/tickets/TicketTags.svelte new file mode 100644 index 000000000..c441e9014 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/TicketTags.svelte @@ -0,0 +1,100 @@ +<script lang="ts"> + import { onMount, onDestroy } from "svelte"; + import type { TicketDetail, WorkspaceTag, WorkspaceTagMutation, TicketTagSetRequest } from "#lib/generated/ticket-api.ts"; + import { workspaceApiJson, workspaceApiJsonWithBody, workspaceApiPath } from "#lib/workspace/api/http.ts"; + import { parseWorkspaceTag, TICKET_BROWSER_API_MAX_RESPONSE_BYTES } from "#lib/workspace/api/ticket-browser.ts"; + import { observeOperation, operationError, parseTags, type ObservedOperation } from "#lib/workspace/api/ticket-operations.ts"; + let { workspaceId, ticket, refresh }: { workspaceId: string; ticket: TicketDetail; refresh: () => Promise<void> } = $props(); + let catalog = $state<WorkspaceTag[]>([]); + let selected = $state<string[]>([]); + let loading = $state(true); + let busy = $state(false); + let error = $state<string | null>(null); + let pending = $state<ObservedOperation | null>(null); + let saved = $state(false); + let name = $state(""); + let tagId = $state(""); + const observed = $derived(catalog.find((tag) => tag.id === tagId)); + let alive = true; + onDestroy(() => { alive = false; }); + const catalogPath = $derived(workspaceApiPath(workspaceId, "/ticket-tags")); + async function load() { + loading = true; + try { + catalog = await workspaceApiJson(catalogPath, parseTags, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + selected = ticket.tags.map((tag) => tag.id); + if (!pending) error = null; + return true; + } catch (cause) { if (alive) error = operationError(cause); return false; } + finally { loading = false; } + } + onMount(() => { void load(); }); + async function run(operation: ObservedOperation, tagResult: boolean) { + if (busy) return; + busy = true; pending = operation; saved = false; error = null; + try { + await workspaceApiJsonWithBody<WorkspaceTag | WorkspaceTag[]>(operation.path, { method: operation.method, body: operation.body }, tagResult ? parseWorkspaceTag : parseTags, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + if (!alive) return; + pending = null; saved = true; + await refresh(); await load(); + } catch (cause) { if (alive) error = saved ? `Tag operation saved; latest projection unavailable. ${operationError(cause)}` : operationError(cause); } + finally { busy = false; } + } + let retryTagResult = $state(false); + function mutateTag(action: WorkspaceTagMutation["action"]) { + retryTagResult = true; + void run(observeOperation(catalogPath, "POST", { action, operation_key: crypto.randomUUID() } satisfies WorkspaceTagMutation), true); + } + function setTags() { + retryTagResult = false; + const request: TicketTagSetRequest = { expected_tag_ids: ticket.tags.map((tag) => tag.id), tag_ids: selected, operation_key: crypto.randomUUID() }; + void run(observeOperation(workspaceApiPath(workspaceId, `/tickets/${encodeURIComponent(ticket.id)}/tags`), "PUT", request), false); + } + async function reconcile() { + if (busy) return; + busy = true; + try { await refresh(); if (!alive || !await load()) return; pending = null; error = null; saved = false; } + catch (cause) { if (alive) error = operationError(cause); } + finally { busy = false; } + } +</script> +<section class="ticket-detail-section ticket-tags"> + <div class="ticket-section-heading"><h2>Classification tags</h2></div> + <p class="workspace-empty-copy">Classification only. Tag changes do not close or reopen this case.</p> + <div class="ticket-tag-chips">{#each ticket.tags as tag (tag.id)}<span class="ticket-tag-chip">{tag.name}</span>{:else}<span>No tags</span>{/each}</div> + {#if loading}<p role="status">Loading tag catalog…</p>{/if} + {#if error}<p class="workspace-callout is-error" role="alert">{error}</p>{/if} + {#if saved}<p role="status">Tag operation saved.</p>{/if} + {#if pending} + <p>Pending operation retained; new operations are disabled.</p> + <button class="workspace-secondary-button" disabled={busy} onclick={() => pending && void run(pending, retryTagResult)}>Retry exact tag operation</button> + <button class="workspace-secondary-button" disabled={busy} onclick={() => void reconcile()}>Refresh and reconcile tags</button> + {/if} + <details><summary>Edit classification</summary> + <form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); setTags(); }}> + <fieldset disabled={busy || loading || pending !== null}><legend>Multiple tags</legend> + {#each catalog.filter((tag) => !tag.deleted) as tag (tag.id)}<label><input type="checkbox" bind:group={selected} value={tag.id} />{tag.name}</label>{/each} + </fieldset> + <button class="workspace-secondary-button" disabled={busy || loading || pending !== null}>Save classification</button> + </form> + </details> + <details><summary>Manage Workspace tags</summary> + <form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); mutateTag({ kind: "create", name: name.trim() }); }}> + <label>Tag name<input bind:value={name} required disabled={busy || pending !== null} /></label> + <button class="workspace-secondary-button" disabled={busy || pending !== null || !name.trim()}>Create tag</button> + </form> + <label>Existing tag<select bind:value={tagId} disabled={busy || pending !== null}><option value="">Select tag</option>{#each catalog.filter((tag) => !tag.deleted) as tag (tag.id)}<option value={tag.id}>{tag.name}</option>{/each}</select></label> + {#if observed} + <p><code>{observed.id}</code></p> + <button class="workspace-secondary-button" disabled={busy || pending !== null || !name.trim()} onclick={() => mutateTag({ kind: "rename", tag_id: observed.id, expected_last_operation_key: observed.last_operation_key, name: name.trim() })}>Rename tag</button> + <button class="workspace-danger-button" disabled={busy || pending !== null} onclick={() => { if (confirm(`Delete classification tag ${observed.name}? Historical audit is retained.`)) mutateTag({ kind: "delete", tag_id: observed.id, expected_last_operation_key: observed.last_operation_key }); }}>Delete tag</button> + {/if} + </details> +</section> +<style> + .ticket-tag-chips { display: flex; flex-wrap: wrap; gap: var(--space-2, 0.5rem); margin-block: 0.75rem; } + .ticket-tag-chip { padding: 0.2rem 0.5rem; background: var(--bg-subtle); border-radius: var(--radius-soft); overflow-wrap: anywhere; } + fieldset label { display: flex; align-items: center; gap: 0.5rem; } + fieldset input { width: auto; } + code { overflow-wrap: anywhere; } +</style> diff --git a/web/workspace/src/lib/workspace/tickets/TicketWorkerObservation.svelte b/web/workspace/src/lib/workspace/tickets/TicketWorkerObservation.svelte new file mode 100644 index 000000000..cf8e6dca1 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/TicketWorkerObservation.svelte @@ -0,0 +1,37 @@ +<script lang="ts"> + import { untrack, onDestroy } from "svelte"; + import type { TicketDetail } from "#lib/generated/ticket-api.ts"; + import type { WorkerSummary } from "#lib/generated/worker-launch-api.ts"; + import { workspaceApiJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; + import { parseWorkerSummary } from "#lib/workspace/api/workers.ts"; + import { workerHref } from "#lib/workspace/resource-links.ts"; + let { workspaceId, ticket }: { workspaceId: string; ticket: TicketDetail } = $props(); + let worker = $state<WorkerSummary | null>(null); + let error = $state<string | null>(null); + let loading = $state(false); + let observationRequest = 0; + onDestroy(() => { observationRequest += 1; }); + async function load() { + const request = ++observationRequest; + const reference = ticket.current_worker?.worker_resource_key; + worker = null; error = null; + if (!reference) { loading = false; return; } + loading = true; + try { + const observed = await workspaceApiJson(workspaceApiPath(workspaceId, `/workers/${encodeURIComponent(reference)}`), parseWorkerSummary, 1024 * 1024); + if (request !== observationRequest) return; + worker = observed; + } + catch (cause) { if (request === observationRequest) error = cause instanceof Error ? cause.message : String(cause); } + finally { if (request === observationRequest) loading = false; } + } + $effect(() => { void ticket.current_worker?.worker_resource_key; untrack(() => { void load(); }); }); +</script> +<section class="ticket-detail-section ticket-worker-observation"> + <div class="ticket-section-heading"><h2>Related Worker — actual state</h2><button class="workspace-secondary-button" disabled={loading} onclick={() => void load()}>Refresh Worker observation</button></div> + {#if loading}<p role="status">Loading Worker observation…</p>{/if} + {#if error}<p role="alert" class="workspace-callout is-error">Worker observation unavailable: {error}</p>{/if} + {#if worker}<p>{worker.runtime_id}/{worker.worker_id}: <strong>{worker.state}</strong> · availability {worker.availability}</p><a href={workerHref(workspaceId, { resource_key: worker.resource_key, display_name: worker.display_name ?? worker.label })}>Open Worker</a> + {:else}<p class="workspace-empty-copy">{ticket.current_worker ? "Retained Worker identity; live state not observed." : "No related Worker recorded."}</p>{/if} + <p class="workspace-empty-copy">Worker state does not determine Ticket case state or self-reported handling.</p> +</section> diff --git a/web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts b/web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts new file mode 100644 index 000000000..de12d5186 --- /dev/null +++ b/web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts @@ -0,0 +1,214 @@ +// @vitest-environment happy-dom +import { cleanup, fireEvent, render, waitFor } from "@testing-library/svelte"; +import { afterEach, expect, test, vi } from "vitest"; +import type { + TicketAssignmentTrackingProjection, + TicketDetail, +} from "#lib/generated/ticket-api.ts"; +import { fixtureDetail } from "../home/dashboard.test-fixtures.ts"; +import TicketHandling from "./TicketHandling.svelte"; +import TicketTags from "./TicketTags.svelte"; +afterEach(() => { + cleanup(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); +}); +const assignment = ( + id = "delegation-1", + status: "waiting" | "done" = "waiting", +): TicketAssignmentTrackingProjection => ({ + workspace_id: "team", + ticket_id: "T-1", + assignment_id: id, + role: "orchestrator", + principal: { kind: "workspace_agent", agent_key: "workspace-orchestrator" }, + assigned_by: "user", + assigned_at: "2026-01-01T00:00:00Z", + current: true, + tracking: { + status, + waiting_reason: status === "waiting" ? "User answer needed" : null, + result: status === "done" ? "Options returned" : null, + }, + last_event_id: "event-1", +}); +function detail(row = assignment()): TicketDetail { + return { + ...fixtureDetail("T-1"), + action_eligibility: { + ...fixtureDetail("T-1").action_eligibility, + can_assign_orchestrator: true, + can_unassign_orchestrator: true, + }, + assignment_tracking: [row], + assignments: [{ + assignment_id: row.assignment_id, + role: row.role, + principal: row.principal, + assigned_by: row.assigned_by, + assigned_at: row.assigned_at, + }], + }; +} +function reads(path: string, row: TicketAssignmentTrackingProjection) { + if (path.includes("/assignment-tracking/assignments")) { + return Response.json({ items: [row], next_assignment_id: null }); + } + if (path.includes("/assignment-diagnostics")) { + return Response.json({ + assignment_id: row.assignment_id, + current: true, + actionable: true, + input_origin_known: true, + inputs: [{ + input_event_id: "input", + delivery_id: "delivery", + notification: "request_persisted", + acceptance_outcome_unknown: true, + }], + starts: [], + next_input_event_id: null, + next_start_operation_id: null, + }); + } + throw new Error(`Unexpected read ${path}`); +} +test("an answer response loss keeps exact assignment/event/key/body for retry even when observed responsibility changes", async () => { + const row = assignment(); + const sent: string[] = []; + let attempts = 0; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + const path = String(input); + if (path.endsWith("/assignment-answer")) { + sent.push(String(init?.body)); + if (++attempts === 1) throw new Error("response lost"); + return Response.json({ + assignment_id: row.assignment_id, + input_event_id: "input-2", + event_index: 2, + }); + } + return reads(path, row); + }), + ); + const refresh = vi.fn(async () => {}); + const view = render(TicketHandling, { + workspaceId: "team", + ticket: detail(row), + orchestrator: null, + refresh, + }); + await fireEvent.input( + view.getByLabelText("Answer / continue this handling"), + { target: { value: "Use option B" } }, + ); + await fireEvent.click(view.getByText("Send answer to this assignment")); + await waitFor(() => expect(view.getByText(/Outcome unknown\./)).toBeTruthy()); + await view.rerender({ ticket: detail(assignment("delegation-2")) }); + expect( + (view.getByText("Send answer to this assignment") as HTMLButtonElement) + .disabled, + ).toBe(true); + await fireEvent.click(view.getByText("Retry exact operation")); + await waitFor(() => expect(refresh).toHaveBeenCalledTimes(1)); + expect(sent).toHaveLength(2); + expect(sent[1]).toBe(sent[0]); + expect(JSON.parse(sent[1])).toEqual({ + assignment_id: "delegation-1", + expected_last_event_id: "event-1", + body: "Use option B", + operation_key: expect.any(String), + }); + expect(view.container.textContent).toContain("Handling status is unchanged"); +}); +test("a done reissue persists before a failed refresh and does not pretend delivery or execution succeeded", async () => { + const row = assignment("delegation-1", "done"); + const sent: unknown[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + sent.push(JSON.parse(String(init.body))); + return Response.json({ + workspace_id: "team", + ticket_id: "T-1", + assignment: { + assignment_id: "delegation-2", + workspace_id: "team", + ticket_id: "T-1", + role: "orchestrator", + principal: row.principal, + assigned_at: row.assigned_at, + assigned_by: row.assigned_by, + }, + }); + } + return reads(String(input), row); + }), + ); + const view = render(TicketHandling, { + workspaceId: "team", + ticket: detail(row), + orchestrator: null, + refresh: vi.fn(async () => { + throw new Error("refresh unavailable"); + }), + }); + await fireEvent.click(view.getByText("Request again (new assignment)")); + await waitFor(() => + expect(view.getByText(/Assignment operation saved: delegation-2/)) + .toBeTruthy() + ); + expect(sent).toHaveLength(1); + expect(sent[0]).toMatchObject({ expected_assignment_id: "delegation-1" }); + expect(view.queryByText("Retry exact operation")).toBeNull(); + expect(view.container.textContent).toContain("Outcome unknown"); + expect(view.container.textContent).toContain( + "Outcome unknown — may already be accepted", + ); + expect(view.container.textContent).toContain( + "No work start records observed", + ); + expect(view.container.textContent).toContain("Self-reported handling: done"); +}); +test("classification conflict keeps stable IDs and the original observed set across an exact retry", async () => { + const tag = { + workspace_id: "team", + id: "tag-1", + name: "closed", + last_operation_key: "tag-create", + deleted: false, + }; + const requests: string[] = []; + vi.stubGlobal( + "fetch", + vi.fn(async (_input: RequestInfo | URL, init?: RequestInit) => { + if (init?.method === "PUT") { + requests.push(String(init.body)); + return new Response("", { status: 409 }); + } + return Response.json([tag]); + }), + ); + const view = render(TicketTags, { + workspaceId: "team", + ticket: { ...detail(), tags: [] }, + refresh: vi.fn(async () => {}), + }); + await waitFor(() => expect(view.queryByText("Loading tag catalog…")).toBeNull()); + await fireEvent.click(view.getByLabelText("closed")); + await fireEvent.click(view.getByText("Save classification")); + await waitFor(() => expect(view.getByText(/Stale observation/)).toBeTruthy()); + await view.rerender({ ticket: { ...detail(), tags: [tag] } }); + await fireEvent.click(view.getByText("Retry exact tag operation")); + await waitFor(() => expect(requests).toHaveLength(2)); + expect(requests[1]).toBe(requests[0]); + expect(JSON.parse(requests[1])).toMatchObject({ + expected_tag_ids: [], + tag_ids: ["tag-1"], + }); + expect((view.getByText("Save classification") as HTMLButtonElement).disabled) + .toBe(true); +}); diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index 63dd10a71..676e21bf2 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -1,7 +1,13 @@ import { + closureReasonLabel, + currentOrchestratorAssignment, + handlingLabel, + handlingSummary, nextTicketLaneVisibleCount, + readTicketFilters, TICKET_LANE_PAGE_SIZE, ticketLanes, + ticketQueryRequest, ticketWorkerLaunchHref, ticketWorkerMessage, } from "./ticket-panel.ts"; @@ -23,56 +29,46 @@ function assertEquals<T>(actual: T, expected: T): void { } } -Deno.test("ticketLanes combines workflow states and sorts by state then update time", () => { +Deno.test("ticketLanes separates open and closed cases and orders updates deterministically", () => { const tickets = [ { - id: "planning", - state: "planning", - title: "Planning", - updated_at: "2026-07-30T12:00:00Z", - }, - { - id: "ready-old", - state: "ready", - title: "Ready old", + id: "old", + state: "open", + title: "Open old", + tags: [{ + workspace_id: "workspace-a", + id: "tag-closed", + name: "closed", + last_operation_key: "create-closed", + deleted: false, + }], updated_at: "2026-07-29T12:00:00Z", }, { - id: "ready-new", - state: "ready", - title: "Ready new", + id: "new", + state: "open", + title: "Open new", + tags: [], updated_at: "2026-07-30T12:00:00Z", }, - { id: "queued", state: "queued", title: "Queued", updated_at: null }, { - id: "inprogress", - state: "inprogress", - title: "In progress", + id: "closed", + state: "closed", + title: "Closed", + tags: [{ + workspace_id: "workspace-a", + id: "tag-ready", + name: "ready", + last_operation_key: "create-ready", + deleted: false, + }], updated_at: null, }, - { id: "closed", state: "closed", title: "Closed", updated_at: null }, - { id: "done", state: "done", title: "Done", updated_at: null }, ] as TicketSummary[]; - const lanes = ticketLanes(tickets); - assertEquals(lanes.map((lane) => lane.id), [ - "ready-planning", - "inprogress-queued", - "done-closed", - ]); - assertEquals(lanes[0].tickets.map((ticket) => ticket.id), [ - "ready-new", - "ready-old", - "planning", - ]); - assertEquals(lanes[1].tickets.map((ticket) => ticket.id), [ - "inprogress", - "queued", - ]); - assertEquals(lanes[2].tickets.map((ticket) => ticket.id), [ - "done", - "closed", - ]); + assertEquals(lanes.map((lane) => lane.id), ["open", "closed"]); + assertEquals(lanes[0].tickets.map((ticket) => ticket.id), ["new", "old"]); + assertEquals(lanes[1].tickets.map((ticket) => ticket.id), ["closed"]); }); Deno.test("ticket lane visibility advances in bounded pages of 30", () => { @@ -122,44 +118,107 @@ Deno.test("generic Ticket launch carries natural text without role or resource c } }); -Deno.test("ticket detail retains backend queue eligibility and generic Worker assignment actions", async () => { - const source = await Deno.readTextFile( - new URL( - "../../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte", - import.meta.url, +Deno.test("case, stable tags and principal filters survive every server-query page", () => { + const filters = readTicketFilters( + new URLSearchParams( + "q=options&case=open&tag=tag-1&tag=tag-2&principal=orchestrator", ), ); + for (const cursor of [null, "next-page"]) { + const request = JSON.parse( + JSON.stringify(ticketQueryRequest(filters, ["open"], cursor)), + ); + assertEquals(request, { + query: "options", + states: ["open"], + tag_ids: ["tag-1", "tag-2"], + assigned_principal: { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", + }, + handling_status: null, + sort: "updated_desc", + limit: 30, + cursor, + }); + const handling = JSON.parse( + JSON.stringify(ticketQueryRequest(filters, [], cursor, "done")), + ); + assertEquals(handling.handling_status, "done"); + assertEquals(handling.states, []); + } +}); - assertEquals(source.includes("ticket.action_eligibility.can_queue"), true); - assertEquals(source.includes("ticket.relations.blockers.length > 0"), true); - assertEquals(source.includes("outcome.queued_tickets.join"), true); +Deno.test("current close-reason queries preserve known and custom reasons on every page without changing case intent", () => { + for ( + const [input, expected] of [["completed", "completed"], [ + "other&reason_text=Superseded%20plan", + { other: "Superseded plan" }, + ]] as const + ) { + const filters = readTicketFilters( + new URLSearchParams(`case=closed&reason=${input}`), + ); + for (const cursor of [null, "next-page"]) { + const request = JSON.parse( + JSON.stringify(ticketQueryRequest(filters, ["closed"], cursor)), + ); + assertEquals(request.closure_reason, expected); + assertEquals(request.states, ["closed"]); + assertEquals(request.cursor, cursor); + } + } +}); + +Deno.test("handling summary uses only the current self-report and never Worker or case status", () => { + const row = { + assignment_id: "a", + current: true, + role: "orchestrator", + tracking: { status: "waiting", waiting_reason: "Awaiting user choice" }, + } as TicketDetail["assignment_tracking"][number]; + for (const state of ["open", "closed"] as const) { + for (const workerState of ["Running", "Stopped"]) { + const value = { + state, + workerState, + assignment_tracking: [{ + ...row, + assignment_id: "released", + current: false, + tracking: { status: "done" as const, result: "Old result" }, + }, row], + }; + const current = currentOrchestratorAssignment(value); + assertEquals(current?.assignment_id, "a"); + assertEquals(handlingLabel(current?.tracking?.status), "Waiting"); + assertEquals(handlingSummary(current), "Awaiting user choice"); + } + } assertEquals( - source.includes("resolve the listed blockers before Queue"), - false, + handlingSummary({ + ...row, + tracking: { status: "done", result: "Compared options" }, + }), + "Compared options", ); assertEquals( - source.includes("ticket.action_eligibility.can_assign_orchestrator"), - true, + handlingSummary({ + ...row, + tracking: null, + migration_diagnostic: "Unknown historical handling", + }), + "Unknown historical handling", ); + assertEquals(handlingLabel(null), "Unknown"); assertEquals( - source.includes('mutateAssignment("start-manual", "worker"'), - true, - ); - assertEquals( - source.includes('mutateAssignment("start-manual", "reviewer"'), - false, - ); - assertEquals(source.includes('kind: "workspace_agent"'), true); - assertEquals(source.includes('kind: "worker"'), true); - assertEquals(source.includes("ticket.assignee"), false); - assertEquals(source.includes('action: "set", targets'), true); - assertEquals(source.includes("updatedTicket.targets"), true); - assertEquals(source.includes("ticket.repository_key"), false); - assertEquals(source.includes("ticket.ref_selector"), false); - assertEquals( - source.includes("(repository.diagnostics ?? []).length > 0"), - false, + currentOrchestratorAssignment({ + assignment_tracking: [{ ...row, current: false }], + }), + null, ); + assertEquals(handlingSummary(null), "Not assigned"); + assertEquals(closureReasonLabel({ other: "Superseded" }), "Superseded"); }); Deno.test("Ticket authoring creates the target collection without singular authority", async () => { diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index fd246e20e..5e743bd62 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -1,57 +1,35 @@ import type { BrowserWorkspaceOrchestratorResponse } from "#lib/generated/worker-launch-api.ts"; import type { + TicketAssignmentPrincipalSnapshot, + TicketAssignmentTrackingProjection, + TicketAssignmentTrackingStatus, + TicketClosureReason, TicketDetail, TicketListItemSummary, + TicketQueryItem, + TicketQueryRequest, } from "#lib/generated/ticket-api.ts"; -export const TICKET_STATES = [ - "planning", - "ready", - "queued", - "inprogress", - "done", - "closed", -] as const; - +export const TICKET_STATES = ["open", "closed"] as const; export type TicketState = (typeof TICKET_STATES)[number]; - export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse; - const LANE_DEFINITIONS = [ - { - id: "ready-planning", - label: "Ready + Planning", - states: ["ready", "planning"], - }, - { - id: "inprogress-queued", - label: "In progress + Queued", - states: ["inprogress", "queued"], - }, - { - id: "done-closed", - label: "Done + Closed", - states: ["done", "closed"], - }, + { id: "open", label: "Open", states: ["open"] }, + { id: "closed", label: "Closed", states: ["closed"] }, ] as const; export const TICKET_LANE_PAGE_SIZE = 30; export type TicketLaneDefinition = (typeof LANE_DEFINITIONS)[number]; export type TicketLaneId = TicketLaneDefinition["id"]; -export type TicketCardSummary = Pick< - TicketListItemSummary, - "id" | "resource_key" | "title" | "state" | "priority" | "updated_at" ->; - -const STATE_SORT_ORDER = new Map<string, number>([ - ["ready", 0], - ["planning", 1], - ["inprogress", 0], - ["queued", 1], - ["done", 0], - ["closed", 1], -]); +export type TicketCardSummary = + & Pick< + TicketListItemSummary, + "id" | "resource_key" | "title" | "state" | "priority" | "updated_at" + > + & Partial< + Pick<TicketQueryItem, "tags" | "current_closure" | "assignment_tracking"> + >; export type TicketLane = { id: TicketLaneId; @@ -77,9 +55,6 @@ export function sortTickets( tickets: TicketCardSummary[], ): TicketCardSummary[] { return [...tickets].sort((left, right) => { - const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) - - (STATE_SORT_ORDER.get(right.state) ?? 99); - if (stateDelta !== 0) return stateDelta; const updatedDelta = updatedAt(right) - updatedAt(left); if (updatedDelta !== 0) return updatedDelta; return left.id.localeCompare(right.id); @@ -116,6 +91,148 @@ export function ticketWorkerLaunchHref( }/workers/new?${params.toString()}`; } +export const HANDLING_STATES = ["waiting", "inprogress", "done"] as const; +export const ORCHESTRATOR_PRINCIPAL = { + kind: "workspace_agent", + agent_key: "workspace-orchestrator", +} as const; + +export function currentOrchestratorAssignment( + ticket: Pick<TicketQueryItem, "assignment_tracking">, +): TicketAssignmentTrackingProjection | null { + return ticket.assignment_tracking.find((row) => + row.current && row.role === "orchestrator" + ) ?? null; +} +export function handlingLabel( + status?: TicketAssignmentTrackingStatus | null, +): string { + return status === "waiting" + ? "Waiting" + : status === "inprogress" + ? "In progress" + : status === "done" + ? "Done" + : "Unknown"; +} +export function assignmentPrincipalLabel( + principal: TicketAssignmentPrincipalSnapshot, +): string { + if (principal.kind === "workspace_agent") { + return `Workspace agent: ${principal.agent_key}`; + } + if (principal.kind === "user") return `User account: ${principal.account_id}`; + return `Worker: ${principal.runtime_id}/${principal.worker_id}`; +} +export function handlingSummary( + row: TicketAssignmentTrackingProjection | null, +): string { + const tracking = row?.tracking; + if (!row) return "Not assigned"; + if (!tracking) return row.migration_diagnostic ?? "Handling unavailable"; + return tracking.status === "waiting" + ? tracking.waiting_reason ?? "Waiting reason not reported" + : tracking.status === "inprogress" + ? tracking.progress ?? "Progress not reported" + : tracking.result ?? "Result not reported"; +} +export function closureReasonLabel(reason: TicketClosureReason): string { + return typeof reason === "object" ? reason.other : ({ + completed: "Completed", + not_planned: "Not planned", + duplicate: "Duplicate", + unknown: "Unknown", + })[reason]; +} +export type TicketFilters = { + query: string; + caseState: "all" | TicketState; + tagIds: string[]; + principal: "all" | "orchestrator" | "user" | "worker"; + accountId: string; + runtimeId: string; + workerId: string; + closureReason: + | "all" + | "completed" + | "not_planned" + | "duplicate" + | "unknown" + | "other"; + closureText: string; +}; +export function readTicketFilters(search: URLSearchParams): TicketFilters { + const principal = search.get("principal"); + const state = search.get("case"); + const reason = search.get("reason"); + return { + query: search.get("q") ?? "", + caseState: state === "open" || state === "closed" ? state : "all", + tagIds: [...new Set(search.getAll("tag"))], + principal: principal === "orchestrator" || principal === "user" || + principal === "worker" + ? principal + : "all", + accountId: search.get("account") ?? "", + runtimeId: search.get("runtime") ?? "", + workerId: search.get("worker") ?? "", + closureReason: + ["completed", "not_planned", "duplicate", "unknown", "other"].includes( + reason ?? "", + ) + ? reason as TicketFilters["closureReason"] + : "all", + closureText: search.get("reason_text") ?? "", + }; +} +export function filterPrincipal( + filters: TicketFilters, +): TicketAssignmentPrincipalSnapshot | null { + if (filters.principal === "orchestrator") return ORCHESTRATOR_PRINCIPAL; + if (filters.principal === "user") { + if (!filters.accountId.trim()) { + throw new Error("Enter an account ID for the user principal filter"); + } + return { kind: "user", account_id: filters.accountId.trim() }; + } + if (filters.principal === "worker") { + if (!filters.runtimeId.trim() || !filters.workerId.trim()) { + throw new Error( + "Enter Runtime and Worker IDs for the Worker principal filter", + ); + } + return { + kind: "worker", + runtime_id: filters.runtimeId.trim(), + worker_id: filters.workerId.trim(), + }; + } + return null; +} +// Every page repeats the same server-side filters. Never search a loaded slice. +export function ticketQueryRequest( + filters: TicketFilters, + states: TicketState[], + cursor: string | null = null, + handlingStatus: TicketAssignmentTrackingStatus | null = null, +): TicketQueryRequest { + return { + query: filters.query.trim() || null, + states, + ...(filters.closureReason === "all" ? {} : { + closure_reason: filters.closureReason === "other" + ? { other: filters.closureText.trim() } + : filters.closureReason, + }), + tag_ids: filters.tagIds, + assigned_principal: filterPrincipal(filters), + handling_status: handlingStatus, + sort: "updated_desc", + limit: TICKET_LANE_PAGE_SIZE as TicketQueryRequest["limit"], + cursor, + }; +} + export function relationLabel(kind: string): string { return kind.replaceAll("_", " "); } diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/+page.svelte index 18d93b0a5..2a8772405 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+page.svelte @@ -31,8 +31,8 @@ {#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if} <div class="home-columns"> <DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} /> - <DashboardSection id="home-active" title="In progress" empty="No in-progress or queued tickets." result={data.dashboard.active} /> - <DashboardSection id="home-recent" title="Recent updates" empty="No done / closed tickets or Objectives yet." result={data.dashboard.recent} /> + <DashboardSection id="home-active" title="Open tickets" empty="No open tickets." result={data.dashboard.active} /> + <DashboardSection id="home-recent" title="Recent updates" empty="No closed tickets or Objectives yet." result={data.dashboard.recent} /> </div> </div> diff --git a/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts index 24fe3ecb3..32cc979a1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts @@ -86,7 +86,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl ); await Promise.all(Object.values(result!.dashboard)); expect(depends).toHaveBeenCalledWith("workspace:home"); - expect(fetch).toHaveBeenCalledTimes(7); + expect(fetch).toHaveBeenCalledTimes(6); for (const [path, init] of fetch.mock.calls) { const url = new URL(String(path), "https://example.test"); expect(init?.method ?? "GET").toBe("GET"); @@ -96,7 +96,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl expect(url.searchParams.get("limit")).toBe("5"); } } - expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done"))) + expect(fetch.mock.calls.some(([url]) => String(url).includes("states=open"))) .toBe(true); expect( fetch.mock.calls.some(([url]) => String(url).includes("states=closed")), @@ -152,7 +152,7 @@ test("one recent source failure preserves the other records and does not assert expect((await screen.findByRole("alert")).textContent).toContain( "Objectives", ); - expect(screen.queryByText("No done / closed tickets or Objectives yet.")) + expect(screen.queryByText("No closed tickets or Objectives yet.")) .toBeNull(); }); @@ -169,7 +169,7 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti const fallback = fixtureFetch(); const fetchFn: typeof fetch = (path, init) => String(path).endsWith("/tickets/T-101") - ? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" })) + ? Promise.resolve(Response.json({ ...fixtureDetail(), state: "closed" })) : fallback(path, init); const feed = await data("home-owner", fetchFn).dashboard.active; expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]); @@ -179,9 +179,9 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti test("empty resources have scoped empty states and no fabricated totals", async () => { mount(data("home-empty")); expect(await screen.findByText("No open Merge Requests.")).toBeTruthy(); - expect(await screen.findByText("No in-progress or queued tickets.")) + expect(await screen.findByText("No open tickets.")) .toBeTruthy(); - expect(await screen.findByText("No done / closed tickets or Objectives yet.")) + expect(await screen.findByText("No closed tickets or Objectives yet.")) .toBeTruthy(); expect(screen.queryAllByRole("link")).toHaveLength(0); }); diff --git a/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.svelte new file mode 100644 index 000000000..99b3af00d --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.svelte @@ -0,0 +1,85 @@ +<script lang="ts"> + import { goto } from "$app/navigation"; + import { untrack } from "svelte"; + import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; + import { parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY } from "#lib/workspace/api/ticket-browser.ts"; + import { currentOrchestratorAssignment, handlingLabel, handlingSummary, ticketQueryRequest, closureReasonLabel } from "#lib/workspace/tickets/ticket-panel.ts"; + import { ticketHref } from "#lib/workspace/resource-links.ts"; + import type { PageData } from "./$types"; + let { data }: { data: PageData } = $props(); + let response = $state(untrack(() => data.tickets)); + let busy = $state(false); + $effect(() => { const latest = data.tickets; untrack(() => { response = latest; }); }); + async function applyFilters(event: SubmitEvent) { + event.preventDefault(); + const search = new URLSearchParams(); + for (const [key, value] of new FormData(event.currentTarget as HTMLFormElement)) if (typeof value === "string" && value) search.append(key, value); + busy = true; + try { await goto(`?${search}`); } finally { busy = false; } + } + async function loadMore() { + if (busy || (response.data && !response.data.page.has_more)) return; + busy = true; + const observedData = data; + const previous = response.data; + const result = await loadJson(fetch, workspaceApiPath(data.workspaceId, "/tickets/query"), { + method: "POST", headers: { "content-type": "application/json" }, + body: JSON.stringify(ticketQueryRequest(data.filters, data.filters.caseState === "all" ? [] : [data.filters.caseState], previous?.page.next_cursor ?? null, data.handling)), + }, parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY); + if (data !== observedData) { busy = false; return; } + if (result.data && previous) { + const items = new Map(previous.items.map((item) => [item.id, item])); + for (const item of result.data.items) items.set(item.id, item); + response = { data: { ...result.data, items: [...items.values()] }, error: null }; + } else response = { data: result.data ?? previous, error: result.error }; + busy = false; + } +</script> + +<svelte:head><title>Orchestrator · {data.workspaceId} + + + diff --git a/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.ts b/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.ts new file mode 100644 index 000000000..fa1c7684f --- /dev/null +++ b/web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.ts @@ -0,0 +1,55 @@ +import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; +import { + parseTicketQueryResponse, + TICKET_BROWSER_API_LOAD_POLICY, +} from "#lib/workspace/api/ticket-browser.ts"; +import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/workers.ts"; +import { + HANDLING_STATES, + readTicketFilters, + ticketQueryRequest, +} from "#lib/workspace/tickets/ticket-panel.ts"; +import type { TicketAssignmentTrackingStatus } from "#lib/generated/ticket-api.ts"; +import type { PageLoad } from "./$types"; + +export const load: PageLoad = async ({ fetch, params, url }) => { + const workspaceId = params.workspaceId; + const filters = { + ...readTicketFilters(url.searchParams), + principal: "orchestrator" as const, + tagIds: [], + closureReason: "all" as const, + closureText: "", + }; + const input = url.searchParams.get("handling"); + const handling = + HANDLING_STATES.includes(input as TicketAssignmentTrackingStatus) + ? input as TicketAssignmentTrackingStatus + : null; + const request = ticketQueryRequest( + filters, + filters.caseState === "all" ? [] : [filters.caseState], + null, + handling, + ); + const [tickets, orchestrator] = await Promise.all([ + loadJson( + fetch, + workspaceApiPath(workspaceId, "/tickets/query"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(request), + }, + parseTicketQueryResponse, + TICKET_BROWSER_API_LOAD_POLICY, + ), + loadJson( + fetch, + workspaceApiPath(workspaceId, "/orchestrator"), + undefined, + parseBrowserWorkspaceOrchestratorResponse, + ), + ]); + return { workspaceId, filters, handling, tickets, orchestrator }; +}; diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index 844e7d806..d653e25f6 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -1,6 +1,6 @@
+

Orchestrator

Ticket cases
+

Principal: workspace-orchestrator · Worker availability: {data.orchestrator.data?.online ? "Online" : data.orchestrator.error ? "Unavailable" : "Offline"}

+ {#if data.orchestrator.error}{/if} +
+ + + + +
+
+ {#each response.data?.items ?? [] as ticket (ticket.id)} + {@const row = currentOrchestratorAssignment(ticket)} + +
{ticket.resource_key}{ticket.title}
+
Handling: {handlingLabel(row?.tracking?.status)} ({row?.tracking ? "self-reported" : "not reported"})Ticket case: {ticket.state}
+

{handlingSummary(row)}

+ {#if row?.tracking?.next_action}

Next action: {row.tracking.next_action}

{/if} + {#if ticket.current_closure}

Close reason: {closureReasonLabel(ticket.current_closure.reason)}

{/if} + Tags: {ticket.tags.length ? ticket.tags.map((tag) => tag.name).join(", ") : "None"} + {#if row?.updated_at}{/if} + View Ticket, handling history and delivery evidence → +
+ {:else} + {#if !response.error}

No current assignments match these filters.

{/if} + {/each} + {#if response.error}{/if} + {#if busy}

Loading assignments…

{:else if response.data?.page.has_more && !response.error}{/if} +
+