From 298f8c133020cefba9e73ca00fff882240f047e8 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 11 Oct 2026 00:47:36 +0900 Subject: [PATCH 01/39] fix: carry full Host summary into compaction worker input --- crates/session-store/src/history.rs | 2 + crates/session-store/src/public_snapshot.rs | 9 +- crates/worker/src/compact/worker.rs | 130 +++- .../src/feature/builtin/flow_transition.rs | 1 + .../src/feature/builtin/memory_lifecycle.rs | 1 + .../builtin/memory_surface_lifecycle.rs | 1 + crates/worker/src/internal_worker.rs | 8 + crates/worker/src/ipc/interceptor.rs | 44 +- crates/worker/src/job.rs | 1 + crates/worker/src/session_capture.rs | 58 +- crates/worker/src/session_history.rs | 101 ++- crates/worker/src/worker.rs | 225 +++++- crates/worker/tests/compact_events_test.rs | 727 +++++++++++++++++- docs/design/compaction.md | 91 ++- .../compaction-summary-validation.md | 69 ++ resources/prompts/internal/compact_system.md | 16 +- 16 files changed, 1444 insertions(+), 40 deletions(-) create mode 100644 docs/development/compaction-summary-validation.md 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 838f394a1..f3ae70d05 100644 --- a/crates/session-store/src/public_snapshot.rs +++ b/crates/session-store/src/public_snapshot.rs @@ -1877,7 +1877,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, } } @@ -2548,6 +2549,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/worker/src/compact/worker.rs b/crates/worker/src/compact/worker.rs index 2e450a2fd..218948e42 100644 --- a/crates/worker/src/compact/worker.rs +++ b/crates/worker/src/compact/worker.rs @@ -379,6 +379,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 { @@ -397,9 +398,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; @@ -412,7 +425,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() { @@ -433,17 +451,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])); } @@ -542,6 +581,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()); @@ -549,8 +667,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 2a716d3a0..6ec10fb3b 100644 --- a/crates/worker/src/feature/builtin/flow_transition.rs +++ b/crates/worker/src/feature/builtin/flow_transition.rs @@ -560,6 +560,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/memory_lifecycle.rs b/crates/worker/src/feature/builtin/memory_lifecycle.rs index 8ef2e9e26..c3ae6a2d3 100644 --- a/crates/worker/src/feature/builtin/memory_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_lifecycle.rs @@ -560,6 +560,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 375caa9c3..f78b451b5 100644 --- a/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs +++ b/crates/worker/src/feature/builtin/memory_surface_lifecycle.rs @@ -376,6 +376,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/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 c32fac642..d2be0e7de 100644 --- a/crates/worker/src/job.rs +++ b/crates/worker/src/job.rs @@ -221,6 +221,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/session_capture.rs b/crates/worker/src/session_capture.rs index f67be5936..dcc14b54d 100644 --- a/crates/worker/src/session_capture.rs +++ b/crates/worker/src/session_capture.rs @@ -725,7 +725,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, } } @@ -878,6 +880,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..2d3a922fc 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 { @@ -108,8 +110,32 @@ pub(crate) fn restore_history_entries( let mut history = Vec::new(); for entry in entries { match entry { - LogEntry::AnnotatedSegmentStart { history: seed, .. } => { + 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 +170,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 051b1e64a..67163155d 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -2801,6 +2801,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. @@ -3145,6 +3146,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()), @@ -4221,6 +4223,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) { @@ -4316,6 +4336,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()) @@ -6610,7 +6631,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()) @@ -6623,7 +6652,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() @@ -6688,11 +6721,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 { @@ -6804,12 +6838,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, @@ -6869,7 +6903,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(), @@ -6901,11 +6936,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 @@ -7014,8 +7046,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() { @@ -7072,8 +7106,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) @@ -7489,6 +7534,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()), @@ -7828,6 +7874,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()), @@ -7957,6 +8004,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()), @@ -8342,6 +8390,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()), @@ -8740,6 +8789,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, @@ -8756,11 +8860,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, @@ -8786,10 +8891,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` \ @@ -8808,7 +8923,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); @@ -10103,6 +10219,7 @@ mod build_summary_prompt_tests { fn test_summary_input(items: &[Item]) -> String { build_summary_input( items, + None, &[], None, SummaryInputOptions { @@ -10115,6 +10232,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![ @@ -10215,6 +10398,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 { @@ -10234,6 +10418,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..84d633759 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,721 @@ 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 (head, targets) = self.worker.list_rewind_targets().unwrap(); + self.worker + .rewind_to(targets[0].id.clone(), head) + .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/docs/design/compaction.md b/docs/design/compaction.md index bcd66f0c9..933bbc851 100644 --- a/docs/design/compaction.md +++ b/docs/design/compaction.md @@ -26,11 +26,96 @@ auto-read files, references, retained tail, and TaskStore context. Exceeding it warning and continues without discarding the generated summary or stripping auto-read/retained content. `0` disables this warning. Normal request-time safety checks still apply after activation. -## Large sessions +## Compactor inputs and continuity policy -Large-session compaction should not send an entire prefix transcript as the summary input. Prefer bounded overview/index inputs plus exploration, then keep the retained tail small and explicit. +The compactor receives the previous Host-produced summary in full as mandatory source +material, separately from the bounded overview of this Segment's ordinary prefix. It must +preserve ongoing user goals, directives, completion conditions, and unresolved work from +that summary and from the current Segment's user messages. Recent progress and the +TaskStore snapshot are supporting evidence, not replacements for the original request. +Important wording or ambiguous completion state should be checked through exploration +rather than reconstructed from recent activity. -This keeps compaction cost predictable and avoids turning a context-recovery mechanism into the largest prompt in the session. +The first model input includes: + +- `## Initial compaction summary (full text)`, when a prior summary exists, containing its + complete body without overview truncation; +- recently touched file references, when available; +- the full current Session TaskStore snapshot; +- `## Conversation overview/index`, a bounded index of the ordinary prefix selected for + summarization, with exploration tools for omitted details. + +The inherited summary is selected by `LoggedSessionHistoryOrigin::CompactionSummary`, +not by message role or pasted text. Only the Host-produced opening compaction summary +receives that origin. Auto-read contents, related-file context, and TaskStore snapshots +remain `DerivedSummary` and follow ordinary prefix/retention policy. A user or system +message containing `[Compacted context summary]` is not sufficient to qualify. + +Restore recognizes one explicitly adopted legacy construction: the first history item of +an `AnnotatedSegmentStart` with `compacted_from: Some(...)`, whose origin is +`DerivedSummary`, whose role is System, and whose text starts with the exact Host summary +marker. That item is interpreted as `CompactionSummary` in restored history. Restore does +not promote later derived items, user messages, unknown origins, or marker-bearing items +without compacted lineage, and does not rewrite the original durable record. + +## Retained tail + +The retained-token budget selects an ordinary history tail, at item granularity. Retained +entries remain in the replacement Segment with their item and metadata preserved, except +for documented binary-attachment projection. They are outside the prefix overview. + +The previous `CompactionSummary` is an explicit exception: its full body is supplied to the +compactor regardless of whether the cut lies before or after it, but the old summary is +excluded from both the ordinary overview and the retained tail. The newly generated +summary supersedes it. Keeping both would duplicate inherited context on every compaction. +Other derived context is not removed merely because its origin is `DerivedSummary`. + +## Limits and large sessions + +Large-session compaction does not send an entire prefix transcript as the initial input. +The ordinary prefix uses a bounded overview/index plus exploration. This keeps the +transcript portion predictable without allowing an overview limit to erase inherited goals. + +`overview_target_tokens` is a packing target, `overview_warning_tokens` emits a warning, +and `overview_deadline_tokens` switches the ordinary overview to a coarse index. These +limits and `compact.overview_tokens` accounting cover the overview only, not the separate +full inherited summary. Small targets, warnings, and coarse fallback must not shorten or +omit that summary. + +The inherited summary still occupies the compactor's context. It counts against +`worker_context_max_tokens`; moving it outside the overview does not exempt it from the +hard input-occupancy check. If mandatory input exceeds that cap, compaction fails explicitly +before sending the oversized request, leaving the source Segment active rather than +silently clipping source material or activating an incomplete replacement. The budget guard +is installed through the internal Worker's normal `WorkerInterceptor`, not an Engine +interceptor that Worker startup would replace. Before a provider usage measurement exists, +the estimate includes the full input, system prompt, and installed tool definitions. Later +provider input measurements include that request shape already. A budget warning is an +append-only history item and is also checked against the hard cap; if it cannot fit alongside +the mandatory input, the run fails explicitly. Provider context-window failures likewise do +not activate a replacement; estimates are not an exact model-token guarantee. + +`summary_target_tokens` guides the new `write_summary` output. A nonzero +`summary_max_tokens` is a hard output bound: an oversized result receives a rewrite request; +if the rewritten output still exceeds the maximum, compaction fails without activation. +This output bound is distinct from overview packing, the compactor input cap, and the +advisory `result_context_max_tokens` warning on the assembled replacement. + +## Assessment boundary + +Deterministic integration tests inspect the actual `LlmClient::Request` to prove full +inherited-summary transport, separation from overview packing, provenance-aware retention, +shared history-restoration behavior, hard-limit rejection without activation, and inheritance +through at least three chained compactions. Their `write_summary` outputs are predetermined +mock responses: they do **not** prove that a model understood or semantically retained user +goals, directives, or completion conditions. Shared restore-path coverage is not a complete +cold Runtime restart assessment. + +Live semantic assessment remains pending. It should use a real model on long, repeated +compactions and compare the resulting summaries against the original request and explicit +completion conditions, including facts near the end of a long inherited summary and facts +absent from recent progress. Report semantic omissions separately from successful transport; +a mock echo or a successful activation cannot establish semantic continuity. ## History integrity diff --git a/docs/development/compaction-summary-validation.md b/docs/development/compaction-summary-validation.md new file mode 100644 index 000000000..aa9880916 --- /dev/null +++ b/docs/development/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/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. From e9397fa0ddb296f65311aad79dcb5c7df6fcf1c5 Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 11 Oct 2026 01:23:30 +0900 Subject: [PATCH 02/39] feat: add shared Workspace Ticket classification tags --- crates/client/src/workspace_product.rs | 181 ++++ crates/server-api/src/lib.rs | 203 +++++ .../src/repository_openapi_typescript.rs | 28 + crates/ticket/src/latest_schema.sql | 38 + crates/ticket/src/lib.rs | 72 +- crates/ticket/src/sqlite_schema.rs | 294 ++++++- crates/ticket/src/tags.rs | 323 +++++++ crates/ticket/src/tags_tests.rs | 801 ++++++++++++++++++ crates/ticket/src/tool.rs | 22 + .../feature/builtin/resource_projection.rs | 75 +- crates/worker/src/feature/builtin/ticket.rs | 179 +++- .../worker/src/feature/builtin/ticket/tags.rs | 284 +++++++ .../src/feature/builtin/ticket/tags/tests.rs | 526 ++++++++++++ crates/workspace-server/src/authority.rs | 34 + .../src/schema_equivalence_tests.rs | 3 + crates/workspace-server/src/server.rs | 110 ++- .../src/server_ticket_tags_tests.rs | 139 +++ crates/workspace-server/src/store.rs | 7 +- .../src/workspace_deletion.rs | 3 + docs/design/workspace-ticket-tags.md | 49 ++ docs/report/T-740-subworker-cwd-scope.md | 9 + docs/report/T-740-validation.md | 50 ++ openapi/server-api.json | 546 +++++++++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 22 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- 29 files changed, 3959 insertions(+), 49 deletions(-) create mode 100644 crates/ticket/src/tags.rs create mode 100644 crates/ticket/src/tags_tests.rs create mode 100644 crates/worker/src/feature/builtin/ticket/tags.rs create mode 100644 crates/worker/src/feature/builtin/ticket/tags/tests.rs create mode 100644 crates/workspace-server/src/server_ticket_tags_tests.rs create mode 100644 docs/design/workspace-ticket-tags.md create mode 100644 docs/report/T-740-subworker-cwd-scope.md create mode 100644 docs/report/T-740-validation.md diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 32411e020..0598e8d07 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -91,6 +91,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, @@ -602,6 +652,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(); @@ -996,6 +1071,112 @@ mod tests { (format!("http://{address}"), receiver, handle) } + #[test] + 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 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("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, 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(); + } + } + #[test] fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() { let temp = tempfile::tempdir().unwrap(); diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index d5758ad75..4c0635ea0 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -376,6 +376,11 @@ impl_openapi_schema!( TicketStateUpdateRequest, TicketSummarySearchQuery, TicketThreadEventRequest, + WorkspaceTicketTagListRequest, + WorkspaceTicketTagMutationRequest, + TicketTagsSetRequest, + WorkspaceTicketTagList, + WorkspaceTicketTag, RuntimeManagementApiError, RuntimeWorkersQuery, RuntimeWorkerSpawnRequest, @@ -2976,6 +2981,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, @@ -7260,6 +7290,9 @@ 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, @@ -7567,6 +7600,15 @@ pub struct TicketQueryRequest { pub review_status: Option, #[serde(default)] 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, @@ -7583,6 +7625,8 @@ 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, @@ -7638,6 +7682,8 @@ 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, @@ -7703,6 +7749,14 @@ macro_rules! transparent_ticket_dto { } transparent_ticket_dto!(TicketRecord, ticket::Ticket); +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); @@ -11121,6 +11175,155 @@ 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!({ + "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"])); + } + + #[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), + ] { + let schema = serde_json::to_value(schema).unwrap(); + assert_eq!(schema["properties"]["tags"]["type"], "array"); + assert_eq!( + schema["properties"]["tags"]["items"]["$ref"], + "#/$defs/WorkspaceTag" + ); + } + } + + #[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() { diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index cd0dcf3cb..4103b51dd 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -423,6 +423,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", diff --git a/crates/ticket/src/latest_schema.sql b/crates/ticket/src/latest_schema.sql index a06926101..30820565a 100644 --- a/crates/ticket/src/latest_schema.sql +++ b/crates/ticket/src/latest_schema.sql @@ -131,3 +131,41 @@ 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); + +-- 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 ee50132b9..a9d0fda02 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -22,9 +22,16 @@ use thiserror::Error; pub mod config; mod sqlite_schema; +mod tags; +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, 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; @@ -116,6 +123,7 @@ fn io_err(path: impl Into, source: io::Error) -> TicketError { 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)?, @@ -360,6 +368,7 @@ pub enum TicketEventKind { Decision, ImplementationReport, StateChanged, + TagsChanged, IntakeSummary, StatusChanged, Close, @@ -375,6 +384,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", @@ -390,6 +400,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(), @@ -408,6 +419,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, @@ -1635,6 +1647,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, @@ -1718,6 +1732,8 @@ 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, @@ -1774,6 +1790,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; @@ -1848,6 +1871,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, }, @@ -1938,6 +1971,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), @@ -1961,6 +1996,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), @@ -2259,6 +2303,7 @@ 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); @@ -2343,6 +2388,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, @@ -2929,6 +2975,7 @@ impl SqliteTicketBackend { let artifacts = self.load_artifacts(conn, ticket_id)?; let relations = self.relation_view_for_meta(conn, &meta)?; Ok(Ticket { + tags: self.load_ticket_tags(conn, ticket_id)?, meta, document: TicketDocument { body: MarkdownText::new(body), @@ -3098,7 +3145,9 @@ 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) } @@ -3173,6 +3222,19 @@ 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") @@ -3253,6 +3315,7 @@ impl TicketBackend for SqliteTicketBackend { .author .unwrap_or_else(|| "SqliteTicketBackend".to_string()); let ticket = Ticket { + tags: Vec::new(), meta, document: TicketDocument { body, @@ -3400,7 +3463,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]; @@ -3854,6 +3918,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, @@ -4664,6 +4729,7 @@ mod tests { fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { TicketSummary { + tags: Vec::new(), id: "000TEST".to_string(), resource_key: Some("T-1".to_string()), slug: "000TEST".to_string(), diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 6b8f3c990..d7fe21617 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,8 +7,8 @@ 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 = 8; +const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; #[derive(Clone, Copy)] @@ -41,6 +41,9 @@ struct ExpectedForeignKey { } const OWNED_TABLES: &[&str] = &[ + "workspace_tags", + "typed_ticket_tags", + "workspace_tag_operations", "typed_tickets", "typed_ticket_targets", "typed_ticket_labels", @@ -96,6 +99,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), @@ -288,12 +342,17 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION, BASELINE_MIGRATION_NAME.to_string(), )]) + || applied == BTreeMap::from([(6, BASELINE_MIGRATION_NAME.to_string())]) { - migrate_v6_to_v7(connection)?; + let previous = *applied.keys().next().expect("one migration"); + if previous == 6 { + migrate_v6_to_v7(connection)?; + } + migrate_v7_to_v8(connection)?; connection .execute( "DELETE FROM ticket_schema_migrations WHERE version = ?1", - params![PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION], + params![previous], ) .map_err(sqlite_err)?; connection @@ -325,6 +384,17 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { /// Verifies the current Ticket-owned SQLite schema without executing DDL. pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { + verify_ticket_schema_shape(connection, false) +} + +/// 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, true) +} + +fn verify_ticket_schema_shape(connection: &Connection, pre_classification: bool) -> Result<()> { let mut diagnostics = Vec::new(); collect_table_diagnostics( @@ -337,7 +407,18 @@ 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 pre_classification { + if applied == BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())]) { + Ok(()) + } else { + Err(TicketError::Sqlite( + "pre-classification schema requires the retained v7 baseline".into(), + )) + } + } else { + validate_applied_migrations(&applied) + }; + if let Err(error) = history { push_diagnostic(&mut diagnostics, error.to_string()); } else if applied.len() != MIGRATIONS.len() { push_diagnostic( @@ -354,6 +435,17 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } 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 +486,47 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { CHILD_FOREIGN_KEYS, ), ] { + if pre_classification + && matches!( + table, + "workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations" + ) + { + continue; + } collect_table_diagnostics(connection, table, columns, foreign_keys, &mut diagnostics); } + if !pre_classification { + 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", @@ -449,6 +579,14 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } for table in OWNED_TABLES { + if pre_classification + && matches!( + *table, + "workspace_tags" | "typed_ticket_tags" | "workspace_tag_operations" + ) + { + continue; + } collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); } @@ -474,6 +612,14 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { .map_err(sqlite_err) } +fn migrate_v7_to_v8(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<()> { connection .execute_batch( @@ -1029,7 +1175,10 @@ mod tests { create_latest_ticket_schema(&connection).unwrap(); connection .execute_batch( - "DROP TABLE typed_ticket_targets; + "DROP TABLE typed_ticket_tags; + DROP TABLE workspace_tag_operations; + DROP TABLE workspace_tags; + DROP TABLE typed_ticket_targets; ALTER TABLE typed_tickets ADD COLUMN repository_id TEXT; ALTER TABLE typed_tickets ADD COLUMN ref_selector TEXT; CREATE TABLE ticket_schema_migrations ( @@ -1137,10 +1286,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([(8, BASELINE_MIGRATION_NAME.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!("latest_schema.sql") + .split_once("-- Workspace tag foundation (v8): shared by fresh schema and v7 upgrade.") + .unwrap() + .0; + 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','ready',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 +1431,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 8 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -1177,7 +1453,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 8 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..cd829693b --- /dev/null +++ b/crates/ticket/src/tags.rs @@ -0,0 +1,323 @@ +//! 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; deleted names may be recreated under a new ID. + +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..4387e4369 --- /dev/null +++ b/crates/ticket/src/tags_tests.rs @@ -0,0 +1,801 @@ +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", "Tag"); + for (i, state) in [ + TicketWorkflowState::Planning, + TicketWorkflowState::Ready, + TicketWorkflowState::Queued, + TicketWorkflowState::InProgress, + TicketWorkflowState::Done, + TicketWorkflowState::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}"), &[], std::slice::from_ref(&tag)), + ) + .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!(after.tags, vec![tag.clone()]); + 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..431b0d376 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -249,6 +249,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) } diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index e249066b4..65e731ec4 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -14,6 +14,7 @@ struct ModelTicketQueryItem { ticket: String, title: String, state: String, + tags: Vec, readiness: Option, priority: Option, created_at: Option, @@ -39,6 +40,7 @@ pub(super) struct ModelTicketDetail { title: String, body: String, state: String, + tags: Vec, readiness: Option, priority: Option, created_at: Option, @@ -94,8 +96,16 @@ 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, @@ -236,6 +246,7 @@ 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 +648,24 @@ fn object_field<'a>( .ok_or_else(|| format!("missing or invalid {key}")) } +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) @@ -703,7 +753,7 @@ mod tests { "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", "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}], "linked_objectives": [], "assignments": [], "implementation_reports": [], "merge_requests": [], "merge_request": null, @@ -738,7 +788,7 @@ mod tests { "linked_tickets": ["00001M0E82D1V"], "linked_ticket_summaries": [{ "id": "00001M0E82D1V", - "resource_key": "T-496", + "resource_key": "T-496", "tags": [], "title": "Ticket", "state": "done", "updated_at": "2026-01-02T00:00:00Z" @@ -769,7 +819,7 @@ mod tests { "record_authority": "workspace_sqlite", "items": [{ "id": "00001TICKETINTERNAL", - "resource_key": "T-543", + "resource_key": "T-543", "tags": [], "title": "Ticket", "state": "inprogress", "readiness": null, @@ -840,7 +890,7 @@ 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", "tags": [], "content_digest": "ticket-content-digest", "title": "Queued Submit", "body": "Body", @@ -943,7 +993,7 @@ mod tests { "review_excerpt": "Reviewed source" }); let projected = project_ticket_detail(json!({ - "resource_key": "T-716", + "resource_key": "T-716", "tags": [], "content_digest": "current-ticket-content-digest", "title": "Source evidence", "body": "Body", @@ -1123,4 +1173,19 @@ mod tests { .expect_err("missing W-key must fail"); assert!(error.contains("W-")); } + #[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 706580a51..4994bfea8 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -4,6 +4,8 @@ //! module binds an authority-scoped Workspace client, declares the built-in //! feature, and contributes those tools through the normal registry path. +mod tags; + use std::collections::{BTreeMap, BTreeSet, HashMap}; use std::sync::{Arc, Mutex}; @@ -167,6 +169,18 @@ struct WorkspaceQueryTicketInput { /// 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, @@ -210,6 +224,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) @@ -502,6 +523,7 @@ fn workspace_ticket_tool_description(name: &str) -> String { "CompleteTicket" => COMPLETE_TICKET_DESCRIPTION.into(), "TicketWorkflowState" => TICKET_STATE_UPDATE_DESCRIPTION.into(), "TicketClose" => TICKET_CLOSE_DESCRIPTION.into(), + name if tags::TAG_TOOL_NAMES.contains(&name) => tags::description(name).into(), _ => ticket_tool_description(name, None), } } @@ -632,6 +654,9 @@ impl TicketFeatureAccess { .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 + })) .collect() } @@ -648,7 +673,7 @@ impl TicketFeatureAccess { } } -const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket"]; +const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket", "WorkspaceTagList"]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", @@ -678,6 +703,11 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketRelationRecord", "TicketRelationRemove", "CompleteTicket", + "WorkspaceTagList", + "WorkspaceTagCreate", + "WorkspaceTagRename", + "WorkspaceTagDelete", + "TicketTagsSet", ]; #[cfg(test)] @@ -693,6 +723,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", "CompleteTicket", + "WorkspaceTagList", + "WorkspaceTagCreate", + "WorkspaceTagRename", + "WorkspaceTagDelete", + "TicketTagsSet", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ @@ -1022,6 +1057,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, @@ -1332,6 +1393,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(), @@ -1577,6 +1667,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, @@ -1721,10 +1841,21 @@ pub(crate) fn enabled_ticket_definitions( .collect(); if allowed.contains(&"CompleteTicket") { definitions.push(workspace_ticket_decision_definition( - client, + client.clone(), 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)); + } + } definitions } @@ -1959,13 +2090,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( @@ -3053,6 +3187,11 @@ mod tests { "record_relation", "remove_relation", "complete", + "tag_list", + "tag_create", + "tag_rename", + "tag_delete", + "tags_set", ] ); let item = ticket_descriptor( @@ -3102,6 +3241,11 @@ mod tests { "record_orchestration_plan", "query_orchestration_plans", "complete", + "tag_list", + "tag_create", + "tag_rename", + "tag_delete", + "tags_set", ] ); assert_eq!( @@ -3110,7 +3254,7 @@ mod tests { .iter() .map(|tool| tool.projection.operation) .collect::>(), - ["query", "read"] + ["query", "read", "tag_list"] ); } @@ -3404,18 +3548,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")); 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..bb29d58c5 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/tags/tests.rs @@ -0,0 +1,526 @@ +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":"planning", "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"], "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()])); + { + let requests = client.requests.lock().unwrap(); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + for field in ["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":"planning", "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"); + 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(_)) + )); + } + 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, TicketWorkflowState::Planning); + 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/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 8f8001af2..d8234a1ed 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -391,6 +391,21 @@ impl SqliteWorkspaceAuthority { .collect::>(); predicates.push(format!("t.workflow_state IN ({})", states.join(","))); } + 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!( @@ -926,6 +941,7 @@ impl SqliteWorkspaceAuthority { queued_at: ticket.meta.queued_at, targets, risk_flags: ticket.meta.risk_flags, + tags: ticket.tags, body, body_truncated, event_count: ticket.events.len(), @@ -982,6 +998,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { state: item.summary.workflow_state.as_str().to_string(), priority: item.summary.priority, updated_at: item.summary.updated_at, + tags: item.summary.tags, queued_by: item.summary.queued_by, queued_at: item.summary.queued_at, workspace_action_priority: workspace_action_priority_name(projection.priority) @@ -1945,6 +1962,13 @@ fn ticket_evidence_summary( } fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { + 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 state in &query.states { if ticket::TicketWorkflowState::parse(state).is_none() { return Err(Error::InvalidRecordId(format!( @@ -2161,6 +2185,13 @@ fn ticket_matches_query( authoritative_events: &[TicketEvent], query: &TicketQueryRequest, ) -> bool { + 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| state == &summary.state) { return false; } @@ -2319,6 +2350,7 @@ fn ticket_query_item( 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, @@ -2675,6 +2707,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, @@ -2702,6 +2735,7 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result Rou .merge(server_api::server_api_axum::ticket_close_record( 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( service.clone(), )) @@ -10410,6 +10419,81 @@ impl server_api::ServerApi for ServerApiContractService { Ok(()) } + async fn workspace_ticket_tag_list( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + request: server_api::WorkspaceTicketTagListRequest, + ) -> std::result::Result + { + let result = execute_ticket_rest_operation_with_actor( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + context.actor.as_ref(), + TicketBackendOperation::ListWorkspaceTags { request: request.0 }, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + 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 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_actor( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + context.actor.as_ref(), + 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::TicketTagsSetRequest, + ) -> std::result::Result + { + let result = execute_ticket_rest_operation_with_actor( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + context.actor.as_ref(), + TicketBackendOperation::SetTicketTags { + id: TicketIdOrSlug::Query(id), + request: request.0, + }, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + 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( &self, context: server_api::ServerRequestContext, @@ -14606,6 +14690,16 @@ async fn execute_ticket_rest_operation( api: &WorkspaceApi, workspace_id: &str, headers: HeaderMap, + operation: TicketBackendOperation, +) -> ApiResult { + execute_ticket_rest_operation_with_actor(api, workspace_id, headers, None, operation).await +} + +async fn execute_ticket_rest_operation_with_actor( + api: &WorkspaceApi, + workspace_id: &str, + headers: HeaderMap, + actor: Option<&RequestActor>, mut operation: TicketBackendOperation, ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; @@ -14654,6 +14748,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) = actor + { + 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( @@ -14784,7 +14884,8 @@ 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( @@ -16948,6 +17049,7 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI | TicketBackendOperation::MarkReady { id, .. } | TicketBackendOperation::QueueReady { id, .. } | TicketBackendOperation::Close { id, .. } + | TicketBackendOperation::SetTicketTags { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. } | TicketBackendOperation::RemoveTicketRelation { id, .. } | TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id), @@ -17015,6 +17117,8 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati 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", @@ -33006,6 +33110,10 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { + mod ticket_tags_tests { + include!("server_ticket_tags_tests.rs"); + } + mod api_logging_tests; mod auth_logging_tests; mod drive_api_tests; 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..7a9393cca --- /dev/null +++ b/crates/workspace-server/src/server_ticket_tags_tests.rs @@ -0,0 +1,139 @@ +use super::*; + +#[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 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":"Bug"} + })).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", "resolution", "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"]); +} + +#[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/store.rs b/crates/workspace-server/src/store.rs index 3db49cc5d..bffe6f466 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -13512,7 +13512,7 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re TICKET_SCHEMA_BASELINE_NAME ], )?; - ticket::verify_sqlite_ticket_schema(db).map_err(|error| { + ticket::verify_pre_classification_ticket_schema(db).map_err(|error| { Error::Store(format!( "schema-66 Ticket schema verification failed: {error}" )) @@ -14896,6 +14896,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 typed_ticket_tags; DROP TABLE workspace_tag_operations; DROP TABLE workspace_tags;")?; conn.execute_batch("DROP TABLE IF EXISTS worker_restore_intents;")?; conn.execute_batch( "DROP TABLE backend_job_deliveries; @@ -15935,7 +15938,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!( diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index dfc1b70da..2e6837f62 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -62,6 +62,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 +94,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", diff --git a/docs/design/workspace-ticket-tags.md b/docs/design/workspace-ticket-tags.md new file mode 100644 index 000000000..ef8fd36ff --- /dev/null +++ b/docs/design/workspace-ticket-tags.md @@ -0,0 +1,49 @@ +# Workspace Ticket classification tags + +T-740 adds classification independently of Ticket state, close judgments, Orchestrator tracking, Worker lifecycle, repository grants, and MR evidence. T-739 owns case-state/close migration; T-743 owns the final Web management UI. + +## Identity and names + +`WorkspaceTag` contains `workspace_id`, immutable `id`, display `name`, `last_operation_key`, and `deleted`. Every operation resolves the ID within the route Workspace, never by display name or an unscoped numeric identity. A rename preserves links; a deleted tag is a tombstone, and recreating its name creates a new identity. + +Names are trimmed with Rust Unicode whitespace trimming, then compared by Unicode lowercase. Display casing is retained. No Unicode canonical/compatibility normalization or locale-specific case folding is implied: canonically equivalent but differently encoded strings can remain distinct. Empty names, control characters, and names longer than 128 UTF-8 bytes are rejected. Active normalized names are unique per Workspace; deleted names are available for reuse. `ready`, `closed`, and other apparent control words are ordinary classification names. + +Legacy `TicketMeta.labels` is not the classification authority and is not reinterpreted or removed by this change. + +## Mutation and observation + +- `GET /api/w/{workspace_id}/ticket-tags`: active tags ordered by stable ID; optional `after` ID and `limit` (bounded to 200). Continue with the last returned ID. A full page is not a promise of another page; continue until exhausted. +- `POST /api/w/{workspace_id}/ticket-tags`: `WorkspaceTagMutation` with `operation_key` and `action` (`kind: create | rename | delete`). Rename/delete require `tag_id` and the observed `expected_last_operation_key`; create/rename require `name`. +- `PUT /api/w/{workspace_id}/tickets/{id}/tags`: `TicketTagSetRequest` with `operation_key`, `expected_tag_ids`, and desired `tag_ids`. Add/remove multiple IDs by computing a desired set from the current detail/list/query projection. This explicit full-set comparison prevents silently overwriting another user's unrelated membership change. IDs are unique sets, not ordered lists for membership comparison; duplicate IDs are rejected. Exact replay retains the original array order as part of the original input. At most 100 active tags may be attached to one Ticket. + +The tag's observed operation key compares the last committed change of that exact identity, including rename-back, before rename/delete. Membership compares the actual current active ID set, not a generic update number, timestamp, body digest, or state. An intervening change which restores exactly the same set is intentionally equivalent: the requested operation depends only on present membership, not on unseen membership history. An observed set containing a subsequently deleted ID is stale and fails, rather than implicitly dropping references. + +SQLite immediate transactions serialize deletion and attachment. Delete atomically tombstones the tag and removes current membership; attachment rejects missing/deleted/foreign-Workspace identities. Neither outcome leaves an active dangling link. Foreign keys preserve Workspace/Ticket/tag identity integrity. + +Operation keys belong to the Workspace classification mutation namespace. Authenticated identity is compared separately from mutable assignment/role context: reassignment does not make an identical receipt into another request, while the original full context remains in the audit. Exact replay compares the operation input, target, and authenticated source identity and returns the original stored response before stale/live checks; it does not repeat audit/event writes. Reusing a key for different input or identity conflicts. A replay is a receipt of the original result, not a fresh observation (its response can contain a subsequently renamed/deleted tag); reread detail/list before editing. Retry an uncertain request with its original key and input. + +## Authorization and history + +Browser and Worker operations use the same Backend tag store and existing Ticket Workspace authorization. The current shared Ticket policy permits authenticated Server users and authorized Runtime-forwarded Workers; tag management does not introduce an owner-only repository/secrets policy or a new membership/grant system. Runtime-forwarded sources are verified using existing source-proof checks, not trusted from arbitrary headers. Authenticated source attribution is retained in audit records; caller-supplied names/roles cannot grant authority. No local filesystem fallback or separate Worker tag namespace is provided. + +Workspace mutations preserve an append-only audit with tag identity and display-name snapshots. Ticket membership changes preserve typed thread events with before/after identity/name snapshots. Deletion preserves tombstones and prior snapshots, so history still means the names and identities observed at the time, not today's display names. Historical events are not rewritten on rename or delete. An unchanged membership set records a receipt without claiming a `tags_changed` thread event. Explicit deletion of the entire Workspace purges its tag catalog, memberships, and receipts through the existing domain purge inventory. + +Classification does not edit the Ticket's editable item content digest, state, close reason, resolution, assignment, or grants; it does not enter routing notifications or start/stop/delegate Workers. Close reasons remain a separate judgment contract even if a UI presents both using similar visual chips. + +## Projections, filtering, and consumers + +Domain `Ticket`/`TicketSummary`, human list/detail/query projections, `ShowTicket`/`QueryTicket`, and typed client responses expose `tags: WorkspaceTag[]`. Deleted tags are absent from current projections but remain in receipts/audit/history. + +Ticket query/search takes stable IDs: + +- `tag_ids`: all IDs must be attached (AND). +- `tag_ids_any`: at least one ID must be attached (OR), unless empty. +- `tag_ids_none`: none may be attached (NOT). + +Nonempty groups combine with AND and with existing text/state/objective/relation/evidence filters. Unknown/deleted/foreign IDs never resolve to another tag: required IDs yield no matches, excluded IDs exclude nothing. Filters are bounded to 100 IDs per group. SQL filtering occurs before bounded candidate selection, then authoritative projections recheck membership. Pagination is not a database snapshot across requests; query-bound cursors cannot be reused with changed filters. + +Tools expose Workspace tag creation/list/rename/delete and Ticket tag-set editing. The WIP native operations use the same tool/client boundary and permissions. OpenAPI is generated from typed ServerApi/domain schemas; browser Ticket types are generated from the canonical OpenAPI document. T-743 should use these public operations and explicit stable IDs, not invent Web-only tag state or depend on tag names for execution policy. + +## Shared integration with T-739 + +Both branches touch Ticket operation enums, summary/detail construction, ServerApi query/detail contracts, tool projections, generated OpenAPI/TypeScript, and Ticket SQLite schema validation. Merge must compose migrations and current schema, not merely pick a higher schema version or overwrite one branch's baseline. Preserve tag tables, receipts, audit rows, tombstones and memberships through case-state migration. Re-run migration/equivalence tests and regenerate public contracts after composition. T-740 itself is buildable on the existing case-state model and does not import T-739's state/close migration. 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/openapi/server-api.json b/openapi/server-api.json index 53424c5a8..1f22b31a2 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -81,6 +81,12 @@ }, "type": "array" }, + "Array_of_WorkspaceTag": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "AttachmentUploadCancelResponse": { "additionalProperties": false, "properties": { @@ -10590,6 +10596,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": { @@ -10622,6 +10649,13 @@ "type": "null" } ] + }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" } }, "required": [ @@ -11017,6 +11051,12 @@ "state": { "type": "string" }, + "tags": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "targets": { "items": { "$ref": "#/components/schemas/TicketTarget" @@ -11035,6 +11075,7 @@ }, "required": [ "id", + "tags", "resource_key", "title", "state", @@ -11471,6 +11512,7 @@ "decision", "implementation_report", "state_changed", + "tags_changed", "intake_summary", "status_changed", "close" @@ -11753,6 +11795,13 @@ "state": { "type": "string" }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -12166,6 +12215,12 @@ "state": { "type": "string" }, + "tags": { + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -12193,6 +12248,7 @@ }, "required": [ "id", + "tags", "resource_key", "title", "state", @@ -12293,6 +12349,30 @@ }, "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" + }, + "type": "array" + }, "updated_after": { "type": [ "string", @@ -12859,6 +12939,13 @@ "status": { "$ref": "#/components/schemas/ExtensibleTicketStatus" }, + "tags": { + "default": [], + "items": { + "$ref": "#/components/schemas/WorkspaceTag" + }, + "type": "array" + }, "title": { "type": "string" }, @@ -12909,6 +12996,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": { @@ -16042,6 +16156,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.", @@ -16135,7 +16360,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3" + "x-yoi-source-digest": "sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066" }, "openapi": "3.1.0", "paths": { @@ -32713,6 +32938,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", @@ -36248,6 +36675,123 @@ ] } }, + "/api/w/{workspace_id}/tickets/{id}/tags": { + "put": { + "operationId": "ticket_tags_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/TicketTagSetRequest" + } + } + }, + "required": true + }, + "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" + }, + "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/{id}/thread-events": { "post": { "operationId": "ticket_thread_event_add", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index a03341919..5881cdd71 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // 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 93145145c..c0abb9755 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // 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 388f6867b..ba6b1cfb7 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // 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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 8818a7ad0..b44a3da32 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,10 +1,12 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // 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 Array_of_WorkspaceTag = Array; + export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; export type BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; }; @@ -91,7 +93,7 @@ export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contri 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; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; 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; state: string; targets: Array; title: string; updated_at?: string | null; }; +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array; assignment_diagnostics: Array; assignments: Array; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array; linked_objectives: Array; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; 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; state: string; tags: Array; targets: Array; 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 +113,7 @@ export type TicketEvidenceSummary = { approved_current_subject: boolean; complet export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array; }; -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 TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; tags?: Array; title: string; updated_at?: string | null; workspace_action_priority: string; }; export type TicketListResponse = { invalid_records: Array; items: Array; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; }; @@ -119,6 +121,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 = { blocker_count: number; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; evidence: TicketEvidenceSummary; id: string; linked_objective_ids: Array; linked_objective_keys: Array; matched_fields: Array; matching_event?: TicketEvidenceEvent | null; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array; priority: string; readiness?: string | null; relation_count: number; resource_key: string; snippet?: string | null; state: string; tags: Array; title: string; unresolved_blocker_count: number; unresolved_review_count: number; updated_at?: string | null; workspace_action_priority: string; }; + +export type TicketQueryRequest = { attention?: Array; cursor?: string | null; event_kinds?: Array; evidence?: Array; 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; tag_ids?: Array; tag_ids_any?: Array; tag_ids_none?: Array; updated_after?: string | null; updated_before?: string | null; }; + +export type TicketQueryResponse = { items: Array; page: QueryPage; record_authority: string; }; + export type TicketQueueOutcome = { queued_tickets: Array; requested_ticket: string; }; export type TicketRef = { id: string; resource_key?: string | null; slug: string; status: TicketStatus; }; @@ -135,6 +143,8 @@ export type TicketSourceRefObservation = { status: "observed"; } | { code: strin export type TicketStatus = "Open" | "Closed"; +export type TicketTagSetRequest = { expected_tag_ids: Array; operation_key: string; tag_ids: Array; }; + export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; }; export type TicketTargetAccess = "read_only" | "read_write"; @@ -142,3 +152,9 @@ 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..4ac81bd89 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:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // 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 8814b40e8..c2d57fe81 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:d65d33e53c3a08f21be2c5d3de1870b4748fdba71e2b960ea773043235977066) // 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 From ba3ebaa68c8b68361da032892d7f26c281af121d Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 11 Oct 2026 01:41:46 +0900 Subject: [PATCH 03/39] feat: separate Ticket case state and immutable closure judgments --- crates/client/src/workspace_product.rs | 515 ++-- crates/manifest/src/profile.rs | 2 +- crates/merge-request/src/lib.rs | 30 +- crates/server-api/src/lib.rs | 369 ++- .../src/repository_openapi_typescript.rs | 63 +- crates/ticket/src/case_schema.sql | 32 + crates/ticket/src/latest_schema.sql | 33 + crates/ticket/src/lib.rs | 2506 ++++------------- crates/ticket/src/sqlite_schema.rs | 156 +- crates/ticket/src/tool.rs | 861 ++---- crates/ticket/tests/case.rs | 800 ++++++ crates/ticket/tests/fixtures/schema7.sql | 134 + crates/ticket/tests/migration.rs | 252 ++ .../src/feature/builtin/orchestration.rs | 60 +- .../feature/builtin/resource_projection.rs | 99 +- crates/worker/src/feature/builtin/ticket.rs | 1320 +++------ crates/worker/src/prompt/catalog.rs | 2 +- crates/worker/src/prompt/system.rs | 8 +- crates/workspace-server/src/authority.rs | 211 +- crates/workspace-server/src/latest_schema.sql | 111 + .../src/legacy_evidence_column_tests.rs | 2 +- crates/workspace-server/src/retention.rs | 264 +- crates/workspace-server/src/server.rs | 981 +++---- .../src/server/tests/api_logging_tests.rs | 34 +- .../src/server/tests/ticket_evidence_tests.rs | 446 ++- .../server/tests/ticket_worker_claim_tests.rs | 57 +- .../tests/ticket_worker_launch_tests.rs | 242 +- crates/workspace-server/src/store.rs | 818 +++++- .../workspace-server/src/workdir_removal.rs | 202 +- .../src/workspace_deletion.rs | 217 +- crates/yoi/src/ticket_cli.rs | 927 +++++- docs/development/ticket-case-state.md | 53 + ...10-10-subworker-cwd-attachment-boundary.md | 10 + openapi/server-api.json | 1662 ++++------- resources/prompts/common/merge-request.md | 2 +- resources/prompts/common/tickets.md | 2 +- resources/prompts/role/coder.md | 2 +- resources/prompts/role/orchestrator.md | 4 +- resources/prompts/role/ticket-worker.md | 2 +- tools/state-contract-name-exceptions.json | 4 +- web/workspace/deno.json | 2 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 36 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../lib/workspace/api/ticket-browser.test.ts | 163 +- .../src/lib/workspace/api/ticket-browser.ts | 219 +- .../src/lib/workspace/api/ticket-case.test.ts | 116 + .../src/lib/workspace/api/ticket-case.ts | 47 + .../console/worker-console.ui.test.ts | 11 +- .../workspace/home/dashboard.test-fixtures.ts | 30 +- .../src/lib/workspace/home/dashboard.ts | 30 +- .../workspace/merge-request-status.test.ts | 16 +- .../src/lib/workspace/styles/tickets.css | 9 +- .../workspace/tickets/ticket-panel.test.ts | 50 +- .../src/lib/workspace/tickets/ticket-panel.ts | 41 +- .../src/routes/w/[workspaceId]/+page.svelte | 4 +- .../w/[workspaceId]/home.browser.spec.ts | 12 +- .../w/[workspaceId]/tickets/+page.svelte | 4 +- .../routes/w/[workspaceId]/tickets/+page.ts | 21 +- .../tickets/[ticketId]/+page.svelte | 147 +- .../test/ticket-detail-route-reuse.test.ts | 1 + web/workspace/vite.config.ts | 5 + 65 files changed, 7625 insertions(+), 6844 deletions(-) create mode 100644 crates/ticket/src/case_schema.sql create mode 100644 crates/ticket/tests/case.rs create mode 100644 crates/ticket/tests/fixtures/schema7.sql create mode 100644 crates/ticket/tests/migration.rs create mode 100644 docs/development/ticket-case-state.md create mode 100644 docs/report/2026-10-10-subworker-cwd-attachment-boundary.md create mode 100644 web/workspace/src/lib/workspace/api/ticket-case.test.ts create mode 100644 web/workspace/src/lib/workspace/api/ticket-case.ts diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 32411e020..33570f44a 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. /// @@ -155,76 +156,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( @@ -664,24 +613,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 +640,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 +704,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 +845,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 +883,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 +907,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,114 +920,228 @@ mod tests { } #[test] - fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() { + 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!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + + #[test] + 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 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(); + 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); + 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(); + } + } + + 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("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(); - assert_eq!( - body["expected_content_digest"], - ticket::ticket_content_digest(&snapshot) - ); - 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") - .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") - .unwrap(); - client - .close_ticket( - &reference.id.into(), - &MarkdownText::new("The request was withdrawn"), - ) - .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(); - } - - #[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":[]}"#, - ); + 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 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(); - assert!( - request.starts_with("POST /api/w/workspace-a/tickets/T-718/state-update "), - "{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()); + 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] @@ -1414,12 +1451,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 454643df7..d0cc45a03 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1329,7 +1329,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/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 21054a748..aace80765 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -1159,18 +1159,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(()) } @@ -1239,18 +1230,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 d5758ad75..6c33ad521 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -306,14 +306,11 @@ impl_openapi_schema!( SkillDetailResponse, SkillActivationResponse, BrowserAppendTicketEventRequest, - BrowserCloseTicketRequest, BrowserEditTicketRequest, BrowserQueueTicketRequest, - BrowserTransitionTicketStateRequest, CancelTicketImplementationRequest, ClearTicketRoleAssignmentQuery, CompleteMergeRequestRequest, - CompleteTicketRequest, CreateTicketOrchestrationPlanRequest, CreateTicketRecordRequest, CreateTicketRelationRequest, @@ -347,9 +344,10 @@ impl_openapi_schema!( SetTicketRoleAssignmentRequest, SubmitMergeRequestReviewRequest, TextResponse, - TicketCloseRecordRequest, TicketDependencyCheckResponse, TicketDetail, + TicketCaseUpdateRequest, + TicketSummary, TicketDoctorResponse, TicketIntakeSummaryRequest, TicketListHttpQuery, @@ -372,8 +370,6 @@ impl_openapi_schema!( TicketRoleAssignmentMutationResponse, TicketRoleAssignmentsResponse, TicketShowRequest, - TicketStateChangeRequest, - TicketStateUpdateRequest, TicketSummarySearchQuery, TicketThreadEventRequest, RuntimeManagementApiError, @@ -3051,14 +3047,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, @@ -3067,31 +3055,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, @@ -3189,22 +3152,14 @@ 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( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: TicketCloseRecordRequest, - ) -> Result<(), RepositoryApiError>; + #[path] ticket_id: String, + #[body] request: TicketCaseUpdateRequest, + ) -> 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, @@ -3292,15 +3247,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, @@ -3327,14 +3273,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, @@ -7263,13 +7201,23 @@ pub struct TicketListItemSummary { 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)] @@ -7553,20 +7501,49 @@ 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 { pub query: Option, #[serde(default)] - pub states: Vec, + 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, pub related_ticket_id: Option, pub relation_kind: Option, pub linked_objective_id: Option, @@ -7585,7 +7562,7 @@ pub struct TicketQueryItem { pub id: String, pub resource_key: String, pub title: String, - pub state: String, + pub state: BrowserTicketState, pub readiness: Option, pub priority: String, pub created_at: Option, @@ -7613,6 +7590,16 @@ pub struct TicketQueryItem { 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)] @@ -7640,7 +7627,7 @@ pub struct TicketDetail { pub id: String, pub resource_key: String, pub title: String, - pub state: String, + pub state: BrowserTicketState, pub readiness: Option, pub priority: String, pub created_at: Option, @@ -7674,6 +7661,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)] @@ -7703,6 +7702,34 @@ 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!(TicketRecordRef, ticket::TicketRef); transparent_ticket_dto!(TicketRecordSummaryList, Vec); transparent_ticket_dto!(TicketDependencyCheckResponse, ticket::TicketDependencyCheck); @@ -7722,10 +7749,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, @@ -7837,15 +7861,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)] @@ -7860,18 +7898,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")] @@ -7896,16 +7922,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 { @@ -8278,17 +8294,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 { @@ -11550,14 +11555,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", @@ -12747,31 +12747,134 @@ 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_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], BrowserTicketState::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 cd0dcf3cb..8bdc73a3c 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -466,11 +466,11 @@ 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: "/api/w/{workspace_id}/tickets/{id}/ready", @@ -493,13 +493,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", @@ -1891,6 +1884,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/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..284272a44 100644 --- a/crates/ticket/src/latest_schema.sql +++ b/crates/ticket/src/latest_schema.sql @@ -131,3 +131,36 @@ 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; diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index ee50132b9..c85112eb1 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -92,6 +92,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}")] @@ -113,6 +118,18 @@ 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 { @@ -125,8 +142,7 @@ fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(8)? != 0, queued_by: row.get(9)?, queued_at: row.get(10)?, @@ -239,61 +255,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()) } @@ -469,53 +458,109 @@ impl TicketStateChange { } } -/// Audited completion independent of repositories, Git, or Merge Requests. +/// Reason attached to a closure judgment, not a classification tag or success inference. +/// `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, +#[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. +/// 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 of the actual editable Ticket requirements: identity, title, body, /// and ordered repository targets. Comments, timestamps, workflow state, and /// event order are not requirements. Restoring identical content restores its @@ -610,7 +655,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)] @@ -797,6 +842,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, @@ -836,6 +882,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, @@ -915,28 +963,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)] @@ -1059,56 +1087,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), @@ -1154,9 +1161,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() @@ -1273,7 +1280,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)] @@ -1320,7 +1327,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)] @@ -1350,7 +1358,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); @@ -1369,13 +1377,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)), } } @@ -1386,78 +1394,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 { @@ -1470,33 +1439,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( @@ -1624,7 +1572,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, @@ -1645,7 +1594,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, @@ -1676,7 +1626,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, } @@ -1724,6 +1674,11 @@ pub struct Ticket { 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)] @@ -1797,24 +1752,9 @@ 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<()>; - fn update_state(&self, _ticket: &str, _request: TicketStateUpdate) -> Result { - Err(TicketError::Conflict( - "backend does not support audited state updates".to_owned(), - )) - } - fn complete(&self, ticket: &str, request: TicketCompletion) -> Result { - self.update_state(ticket, request.into()) - } + 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, @@ -1879,22 +1819,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, @@ -1904,10 +1832,7 @@ pub enum TicketBackendOperation { id: TicketIdOrSlug, queued_by: String, }, - Close { - id: TicketIdOrSlug, - resolution: MarkdownText, - }, + AddTicketRelation { id: TicketIdOrSlug, relation: NewTicketRelation, @@ -1993,19 +1918,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)?) @@ -2013,10 +1928,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)?) } @@ -2223,20 +2135,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", ) @@ -2265,14 +2171,7 @@ impl SqliteTicketBackend { 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(), } @@ -2352,8 +2251,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, @@ -2416,6 +2316,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) @@ -2440,168 +2350,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 { @@ -2904,8 +2665,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)?, @@ -2928,7 +2688,48 @@ 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, meta, document: TicketDocument { body: MarkdownText::new(body), @@ -3103,7 +2904,7 @@ impl SqliteTicketBackend { 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)?; @@ -3115,10 +2916,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) } @@ -3175,9 +2973,9 @@ fn finish_sqlite_transaction(conn: &Connection, result: Result) -> Result< impl TicketBackend for SqliteTicketBackend { 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") } } @@ -3215,10 +3013,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 @@ -3253,6 +3049,10 @@ 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, meta, document: TicketDocument { body, @@ -3260,7 +3060,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, @@ -3278,11 +3078,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)) @@ -3442,8 +3261,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<()> { @@ -3473,57 +3294,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) }) } @@ -3536,9 +3372,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(), }); } @@ -3599,10 +3435,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." @@ -3617,7 +3453,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)?; @@ -3641,6 +3477,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()), @@ -3656,7 +3493,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 { @@ -3665,9 +3502,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(), @@ -3680,29 +3517,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, @@ -3935,16 +3749,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); @@ -3984,7 +3795,7 @@ fn transitive_dependency_blockers( fn collect( ticket: &str, requested_ticket: &str, - states: &HashMap, + states: &HashMap, prerequisites: &BTreeMap>, marks: &mut BTreeMap, blockers: &mut BTreeMap, @@ -4066,7 +3877,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 { @@ -4076,7 +3887,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(), @@ -4104,7 +3915,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(), @@ -4336,6 +4147,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!( @@ -4345,26 +4165,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() { @@ -4662,7 +4462,7 @@ mod tests { )); } - fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { + fn summary_with_state(state: TicketState) -> TicketSummary { TicketSummary { id: "000TEST".to_string(), resource_key: Some("T-1".to_string()), @@ -4681,7 +4481,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(), @@ -4705,9 +4505,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"), @@ -4719,7 +4519,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!( @@ -4735,141 +4535,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] @@ -4878,10 +4613,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(); @@ -4903,14 +4638,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(); @@ -4920,7 +4654,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] @@ -4930,11 +4664,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(); @@ -4961,13 +4695,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, }) @@ -4978,8 +4711,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) @@ -5013,11 +4745,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 [ @@ -5047,7 +4779,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); assert_eq!( backend @@ -5055,7 +4787,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Done + TicketState::Closed ); } @@ -5066,15 +4798,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 [ @@ -5109,7 +4841,7 @@ mod tests { ); let page = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 10, after: None, }) @@ -5130,7 +4862,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!( @@ -5139,7 +4871,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); } } @@ -5151,11 +4883,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 @@ -5180,7 +4912,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!( @@ -5189,7 +4921,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); } } @@ -5202,7 +4934,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")]; @@ -5263,11 +4995,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); @@ -5394,13 +5123,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( @@ -5478,7 +5207,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:?}"); @@ -5493,13 +5222,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); @@ -5523,20 +5248,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, }) @@ -5545,7 +5270,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() @@ -5557,7 +5282,7 @@ mod tests { let second = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 1, after: first.next, }) @@ -5603,10 +5328,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(); @@ -5616,7 +5353,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| { @@ -5649,38 +5386,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(); @@ -5751,1116 +5456,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(); @@ -6908,99 +5503,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(); @@ -7024,7 +5526,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![ @@ -7062,8 +5564,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 @@ -7113,7 +5616,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")]; @@ -7129,7 +5632,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(ready.meta.workflow_state, TicketState::Open); assert_eq!( ready .meta @@ -7146,12 +5649,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); @@ -7184,39 +5687,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")) @@ -7266,11 +5742,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( @@ -7288,15 +5764,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( @@ -7316,10 +5792,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] @@ -7327,7 +5800,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 @@ -7348,7 +5821,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, @@ -7356,7 +5829,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 @@ -7377,10 +5850,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..40bd2905a 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,7 +7,7 @@ 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 LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 9; const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; @@ -52,6 +52,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", ]; @@ -290,6 +293,7 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { )]) { migrate_v6_to_v7(connection)?; + migrate_v7_to_v9(connection)?; connection .execute( "DELETE FROM ticket_schema_migrations WHERE version = ?1", @@ -307,7 +311,11 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { ], ) .map_err(sqlite_err)?; + } else if applied == BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_string())]) { + migrate_v7_to_v9(connection)?; + connection.execute("UPDATE ticket_schema_migrations SET version = 9, applied_at = ?1 WHERE version = 7", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?; } else { + // Version 8 belongs to T-740; compose its tag migration explicitly at integration. validate_applied_migrations(&applied)?; } @@ -448,6 +456,63 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics); } + 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()); + } + } + 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 exists = connection + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = ?1", + params![name], + |_| Ok(()), + ) + .optional() + .map_err(sqlite_err)? + .is_some(); + if !exists { + push_diagnostic( + &mut diagnostics, + format!("missing Ticket case trigger {name}"), + ); + } + } for table in OWNED_TABLES { collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); } @@ -474,6 +539,85 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { .map_err(sqlite_err) } +/// Independent 7→9 case migration. Does not rewrite historical events or +/// create/resume worker identities. Version 8 tag composition is deliberately separate. +fn migrate_v7_to_v9(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-v9:{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-v9".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-v9', ?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 migrate_v6_to_v7(connection: &Connection) -> Result<()> { connection .execute_batch( @@ -1026,7 +1170,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,7 +1283,7 @@ 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([(9, BASELINE_MIGRATION_NAME.to_owned())]) ); } @@ -1155,7 +1301,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 9 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -1177,7 +1323,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 9 baseline marker" )); assert!(!table_exists(&connection, "typed_tickets").unwrap()); } diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 7b5a2f310..b0b1fe8e3 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -17,7 +17,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 +33,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 +44,7 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [ "TicketImplementationReport", "TicketMarkReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketDoctor", ]; @@ -68,7 +67,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 +79,7 @@ pub const TICKET_TOOL_NAMES: [&str; 20] = [ "TicketMarkReady", "TicketIntakeReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketDoctor", "TicketRelationRecord", @@ -100,7 +98,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 +108,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [ "TicketMarkReady", "TicketIntakeReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", @@ -133,18 +130,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 +171,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, @@ -301,33 +294,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 +310,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 +376,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 +413,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 +437,7 @@ impl TicketWorkflowStateParam { #[serde(rename_all = "snake_case")] enum QueryTicketStateParam { Active, - Planning, - Ready, - Queued, - Inprogress, - Done, + Open, Closed, All, } @@ -498,11 +445,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 +454,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 +549,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 +818,7 @@ struct TicketQueueTool { } #[derive(Clone)] -struct TicketWorkflowStateTool { - backend: TicketToolBackend, -} - -#[derive(Clone)] -struct TicketCloseTool { +struct TicketCaseUpdateTool { backend: TicketToolBackend, } @@ -933,7 +867,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 +875,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 +890,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 +903,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 +1117,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 +1149,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 +1200,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 +1651,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 +1766,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 +1812,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 +1839,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 +1926,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 +1959,7 @@ mod tests { "TicketMarkReady", "TicketIntakeReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord" @@ -2069,24 +1979,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 +2043,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 +2165,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 +2223,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 +2247,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 +2263,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 +2415,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 +2435,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 +2451,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 +2463,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 +2473,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 +2526,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 +2677,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/migration.rs b/crates/ticket/tests/migration.rs new file mode 100644 index 000000000..7909a48ac --- /dev/null +++ b/crates/ticket/tests/migration.rs @@ -0,0 +1,252 @@ +//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots +//! migrate independently to schema9, 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, 9); + 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 schema8_tag_composition_is_rejected_until_explicitly_integrated() { + 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 + ); +} diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index bf864d6c9..abbdca4e2 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -247,13 +247,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 +295,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, @@ -315,7 +315,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), @@ -372,7 +372,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 +395,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 +434,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", @@ -603,7 +605,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..9834335fe 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -13,7 +13,10 @@ 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, readiness: Option, priority: Option, created_at: Option, @@ -38,11 +41,15 @@ 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, readiness: Option, priority: Option, created_at: Option, updated_at: Option, + closure_history: Vec, thread: Vec, relations: ModelTicketRelations, linked_objectives: Vec, @@ -216,6 +223,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 +252,10 @@ fn project_ticket_query_item(value: &Value) -> Result Result, - /// Exact workflow states. Empty means every state. + /// Exact case states (open or closed). Empty means every state. #[serde(default)] states: Vec, /// Exact typed event kinds that must occur in the bounded thread window. @@ -159,8 +153,8 @@ 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, @@ -287,88 +281,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 } } @@ -378,97 +342,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( @@ -499,9 +428,7 @@ 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(), _ => ticket_tool_description(name, None), } } @@ -522,7 +449,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 { @@ -631,7 +558,6 @@ impl TicketFeatureAccess { .iter() .copied() .filter(|name| self.allows_tool(name)) - .chain((self.authoring || self.workflow || self.thread).then_some("CompleteTicket")) .collect() } @@ -640,7 +566,7 @@ 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)) @@ -655,7 +581,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketEditItem", "TicketMarkReady", "TicketQueue", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", ]; @@ -673,11 +599,9 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketComment", "TicketMarkReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", - "CompleteTicket", ]; #[cfg(test)] @@ -685,19 +609,16 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "QueryTicket", "ShowTicket", "TicketComment", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", - "CompleteTicket", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", @@ -972,49 +893,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, @@ -1069,6 +947,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, @@ -1107,15 +990,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, @@ -1124,57 +1001,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, @@ -1191,36 +1042,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)?; @@ -1408,44 +1229,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 }), @@ -1478,15 +1264,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, @@ -1625,20 +1402,8 @@ fn native_ticket_operation(tool_name: &str) -> Option { identity_field: Some("ticket"), mutating: true, }, - "TicketWorkflowState" => NativeTicketOperation { - operation: "transition", - 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, @@ -1693,7 +1458,7 @@ pub(crate) fn enabled_ticket_definitions( ) -> Vec { let backend = TicketToolBackend::new(WorkspaceHttpTicketBackend::new(client.clone())); let allowed = access.tool_names(); - let mut definitions: Vec = ticket_tools(backend) + ticket_tools(backend) .into_iter() .filter_map(|definition| { let (meta, _) = definition(); @@ -1707,25 +1472,14 @@ 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, - )); - } - definitions + .collect() } fn native_ticket_tools( @@ -2387,13 +2141,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(); @@ -2412,511 +2169,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, @@ -2934,70 +2428,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 @@ -3009,7 +2522,7 @@ mod tests { bound .handler .call( - "complete", + "case_update", &arguments, WipCallContext { execution: ToolExecutionContext::direct(), @@ -3017,13 +2530,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] @@ -3048,11 +2580,9 @@ mod tests { "comment", "mark_ready", "queue", - "transition", - "close", + "case_update", "record_relation", "remove_relation", - "complete", ] ); let item = ticket_descriptor( @@ -3094,14 +2624,12 @@ mod tests { "query", "read", "comment", - "transition", - "close", + "case_update", "dependency_check", "record_relation", "remove_relation", "record_orchestration_plan", "query_orchestration_plans", - "complete", ] ); assert_eq!( @@ -3253,7 +2781,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; @@ -3332,7 +2860,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(_) )); @@ -3393,7 +2921,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!( @@ -3404,13 +2932,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_eq!(tool_names, WORKSPACE_AUTHORING_TOOL_NAMES); assert!( tool_names.len() < 13, "authoring catalog must stay below the prior broad catalog" ); let workflow_names = TicketFeatureAccess::workflow().tool_names(); - assert_eq!(workflow_names.len(), 11); + assert_eq!(workflow_names, WORKFLOW_TOOL_NAMES); assert!( workflow_names.len() < 12, "workflow catalog must stay below the prior broad catalog" @@ -3482,7 +3010,7 @@ mod tests { assert!(workspace_tools.contains(&"TicketCreate")); assert!(workspace_tools.contains(&"TicketEditItem")); assert!(workspace_tools.contains(&"TicketQueue")); - assert!(workspace_tools.contains(&"TicketWorkflowState")); + assert!(workspace_tools.contains(&"TicketCaseUpdate")); let orchestration = workspace_feature(TicketFeatureAccess::workflow()); let orchestration_descriptor = orchestration.descriptor(); @@ -3491,7 +3019,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")); @@ -3506,8 +3034,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(); @@ -3516,8 +3043,7 @@ 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] @@ -3566,7 +3092,7 @@ mod tests { 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() @@ -3623,7 +3149,7 @@ mod tests { let error = backend .invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody { - from: "planning".to_string(), + from: "open".to_string(), }) .unwrap_err(); @@ -3813,7 +3339,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()); @@ -3843,7 +3369,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/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index a1901a27d..cdb30cfae 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -796,7 +796,7 @@ mod tests { "ReviewMergeRequest", "CheckMergeRequestReadiness", "CompleteMergeRequest", - "CompleteTicket", + "TicketCaseUpdate", ] { assert!(combined.contains(operation), "missing {operation}"); } diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index 4fbff3406..294cd98d1 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -384,12 +384,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 +461,12 @@ 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") ); } } diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 8f8001af2..8c0b176c5 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -7,11 +7,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, }; @@ -282,7 +282,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 @@ -387,7 +387,7 @@ 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(","))); } @@ -438,14 +438,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 @@ -459,17 +452,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()))); @@ -807,24 +796,13 @@ 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 + // Case openness is not readiness. These are explicit operations; + // occupancy remains an independent resource constraint. + can_assign_orchestrator: !has_orchestrator && !has_worker, + can_unassign_orchestrator: has_orchestrator, + can_queue: ticket.meta.workflow_state == TicketState::Open, + can_start_manual_worker: ticket.meta.workflow_state == TicketState::Open + && !has_worker && !has_blockers, queue_tickets: dependency_check.queue_tickets.clone(), blockers: action_blockers, @@ -916,12 +894,16 @@ impl SqliteWorkspaceAuthority { 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, @@ -963,6 +945,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)?; @@ -970,24 +990,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 { @@ -1005,7 +1010,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}`")) }) }) @@ -1029,7 +1034,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 @@ -1945,13 +1954,6 @@ 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}`" - ))); - } - } for evidence in &query.evidence { if !matches!( evidence.as_str(), @@ -1962,25 +1964,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, @@ -2125,19 +2108,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") } @@ -2209,11 +2190,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; @@ -2323,6 +2304,9 @@ fn ticket_query_item( 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), @@ -2682,10 +2666,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 { @@ -2699,13 +2687,16 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result 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/server.rs b/crates/workspace-server/src/server.rs index 339facd0e..ed9350433 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -99,8 +99,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, @@ -6551,21 +6551,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(), )) @@ -6603,10 +6591,7 @@ 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( - service.clone(), - )) - .merge(server_api::server_api_axum::ticket_close_record( + .merge(server_api::server_api_axum::ticket_case_update( service.clone(), )) .merge(server_api::server_api_axum::ticket_relation_view( @@ -6634,15 +6619,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(), )) @@ -10055,24 +10036,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, @@ -10091,43 +10054,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, @@ -10356,58 +10282,47 @@ 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( State(self.workspace_api()?.clone()), contract_request_headers(&context)?, - AxumPath((workspace_id, id)), - Json(request), + 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)) - } - - async fn ticket_state_update( - &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()), - contract_request_headers(&context)?, - AxumPath((workspace_id, id)), - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(server_api::TicketRecord(response)) - } - - async fn ticket_close_record( - &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)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(()) + 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, + 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_relation_view( @@ -10603,24 +10518,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, @@ -10678,24 +10575,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, @@ -13721,17 +13600,6 @@ async fn scoped_set_ticket_assignment( }; let assignment = 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( @@ -13808,18 +13676,6 @@ async fn scoped_clear_ticket_assignment( .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, @@ -13862,18 +13718,6 @@ async fn scoped_cancel_ticket_implementation( .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, @@ -13956,10 +13800,11 @@ fn validate_ticket_assignment_state( 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 = @@ -14140,25 +13985,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(()) } @@ -14321,49 +14155,6 @@ async fn scoped_edit_ticket_item( 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) -} - async fn scoped_append_ticket_event( State(api): State, AxumPath(path): AxumPath, @@ -14426,36 +14217,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, } } @@ -14609,6 +14373,16 @@ async fn execute_ticket_rest_operation( mut operation: TicketBackendOperation, ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; + if let TicketBackendOperation::UpdateCase { ticket, request } = operation { + let Json(updated) = scoped_ticket_case_update( + State(api.clone()), + headers, + AxumPath((workspace_id.to_string(), ticket)), + Json(request), + ) + .await?; + return Ok(TicketBackendOperationResult::Ticket(updated)); + } validate_ticket_operation_repository_keys(api, workspace_id, &operation)?; let mut backend = SqliteTicketBackend::open_verified( api.config.database_path.clone(), @@ -14631,8 +14405,7 @@ async fn execute_ticket_rest_operation( && (generic_ticket_state_change(&operation).is_some() || matches!( operation, - TicketBackendOperation::Close { .. } - | TicketBackendOperation::QueueReady { .. } + TicketBackendOperation::QueueReady { .. } | TicketBackendOperation::MarkReady { .. } )) { @@ -14701,53 +14474,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 { @@ -14791,8 +14521,8 @@ async fn execute_ticket_rest_operation( api, workspace_id, ticket_id, - TicketWorkflowState::Ready.as_str(), - TicketWorkflowState::Queued.as_str(), + "open", + "open", source.clone(), ); } @@ -15015,22 +14745,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)>, @@ -15059,41 +14773,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)>, @@ -16680,34 +16359,11 @@ async fn scoped_complete_merge_request( .map_err(Into::into) } -async fn scoped_complete_ticket( +async fn scoped_ticket_case_update( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): 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 -} - -async fn scoped_ticket_state_update( - State(api): State, - headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(mut request): Json, + Json(mut request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; @@ -16743,14 +16399,14 @@ 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 { 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)) } @@ -16762,30 +16418,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)>, @@ -16943,11 +16575,8 @@ 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::AddTicketRelation { id, .. } | TicketBackendOperation::RemoveTicketRelation { id, .. } | TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id), @@ -16982,9 +16611,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()); @@ -17009,12 +16636,10 @@ 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::AddTicketRelation { .. } => "add_relation", TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation", TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record", @@ -17028,8 +16653,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(), } } @@ -17106,9 +16731,46 @@ fn orchestrator_interested( api: &WorkspaceApi, workspace_id: &str, ticket_id: &str, - state: &str, + _state: &str, ) -> Result> { - if !matches!(state, "queued" | "inprogress") { + let backend = browser_ticket_backend(api)?; + let ticket = backend.show(ticket_id.into()).map_err(Error::from)?; + if ticket.meta.workflow_state != TicketState::Open { + return Ok(None); + } + let has_unfinished_work = api + .store + .get_active_ticket_worker_assignment(workspace_id, ticket_id)? + .is_some(); + // Transitional routing continues to require an explicit queue request or + // unfinished work. Openness alone is not a routing request. A later case + // decision fences older queue receipts; T-741/742 will replace this path. + let explicitly_queued = ticket + .events + .iter() + .rev() + .find_map(|event| { + if event.attributes.contains_key("queue_root_ticket") { + Some(true) + } else if event.attributes.contains_key("case_action") { + Some(false) + } else { + None + } + }) + .unwrap_or_else(|| { + // Old queued/inprogress cases may predate queue receipts. This frozen + // migration handoff preserves their intent, but any new case decision + // fences it so reopening never revives an old routing request. + ticket.case_history.is_empty() + && ticket + .legacy_workflow_snapshot + .as_ref() + .is_some_and(|snapshot| { + matches!(snapshot.workflow_state.as_str(), "queued" | "inprogress") + }) + }); + if !has_unfinished_work && !explicitly_queued { return Ok(None); } active_orchestrator_assignment(api, workspace_id, ticket_id) @@ -18660,28 +18322,16 @@ fn check_orchestrator_queue_attention( return; }; let Ok(mut queued) = backend.list(ticket::TicketListQuery::states([ - ticket::TicketListState::Queued, + ticket::TicketListState::Open, ])) else { return; }; queued.retain(|ticket| { - orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "queued") + orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "open") .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; @@ -32749,6 +32399,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(_) @@ -32869,6 +32520,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( @@ -37758,8 +37410,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 @@ -37786,7 +37439,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); @@ -37814,8 +37467,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 @@ -38235,7 +37888,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), @@ -38530,7 +38183,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); } @@ -38729,7 +38382,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), @@ -48112,7 +47765,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( @@ -48158,7 +47811,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") @@ -48166,17 +47819,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] @@ -48211,7 +47867,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] @@ -48229,7 +47885,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), @@ -48451,10 +48107,10 @@ mod tests { let inputs = execution.take_inputs(); let expected_states = [ - ("ready", "queued"), - ("inprogress", "inprogress"), - ("inprogress", "inprogress"), - ("inprogress", "inprogress"), + ("open", "open"), + ("open", "open"), + ("open", "open"), + ("open", "open"), ]; assert_eq!(inputs.len(), expected_states.len()); for ((recipient, content), (previous_state, current_state)) in @@ -48473,6 +48129,23 @@ mod tests { } } + 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, + } + } + async fn seed_live_test_coder_assignment( api: &WorkspaceApi, ticket_id: &str, @@ -48521,20 +48194,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())), @@ -48544,26 +48222,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); } @@ -48573,20 +48231,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())), @@ -48594,10 +48257,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 @@ -48614,9 +48277,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(), @@ -48647,7 +48310,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()); } @@ -48656,7 +48319,7 @@ 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; @@ -48673,35 +48336,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 = || { @@ -48711,7 +48377,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 @@ -48719,9 +48385,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 @@ -48860,7 +48526,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 @@ -48874,7 +48540,7 @@ mod tests { 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") + ticket_notification_content(ticket.resource_key.as_deref().unwrap(), "open", "open") ); assert!( api.store @@ -48971,7 +48637,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), @@ -49050,8 +48716,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 @@ -49284,8 +48951,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::Ready.as_str(), + .state + .as_str(), + TicketState::Open.as_str(), "case={case}" ); assert!( @@ -49355,8 +49023,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::Ready.as_str() + .state + .as_str(), + TicketState::Open.as_str() ); assert!( fixture @@ -49622,7 +49291,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( @@ -49658,7 +49327,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 @@ -49671,7 +49340,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] @@ -49983,46 +49652,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) @@ -50031,27 +49691,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] @@ -50071,7 +49735,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 @@ -50088,7 +49752,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( @@ -50114,11 +49778,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 [ @@ -50155,7 +49819,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 @@ -50173,11 +49837,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 @@ -50207,7 +49871,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 @@ -50215,7 +49879,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); } @@ -50260,7 +49924,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( @@ -50341,7 +50005,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 = @@ -50405,8 +50069,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() @@ -50420,7 +50084,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"); @@ -50438,8 +50102,8 @@ mod tests { notifications[0].1, ticket_notification_content( ticket_ref.resource_key.as_deref().unwrap(), - TicketWorkflowState::Queued.as_str(), - TicketWorkflowState::Queued.as_str() + TicketState::Open.as_str(), + TicketState::Open.as_str() ) ); @@ -50472,8 +50136,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() @@ -50509,10 +50173,14 @@ mod tests { 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); + 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); + browser_ticket_backend(&api) + .unwrap() + .queue_ready(ticket.id.into(), "test-request") + .unwrap(); (dir, api, execution) } @@ -50796,10 +50464,14 @@ mod tests { // 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); + next.workflow_state = Some(TicketState::Open); 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); + browser_ticket_backend(&api) + .unwrap() + .queue_ready(next.id.clone().into(), "test-request") + .unwrap(); *execution.input_failure.lock().unwrap() = None; assert_eq!( start_attention_test_orchestrator(&api).await.disposition, @@ -51116,10 +50788,14 @@ mod tests { .await .unwrap(); let mut input = ticket::NewTicket::new("Real notification backlog"); - input.workflow_state = Some(TicketWorkflowState::Queued); + 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); + browser_ticket_backend(&api) + .unwrap() + .queue_ready(ticket.id.clone().into(), "test-request") + .unwrap(); let started = start_attention_test_orchestrator(&api).await; let worker = RuntimeWorkerRef::new( EMBEDDED_WORKER_RUNTIME_ID, @@ -51323,10 +50999,14 @@ mod tests { 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); + input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut input, "test-repository", "HEAD"); let ticket_ref = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket_ref.id); + browser_ticket_backend(&api) + .unwrap() + .queue_ready(ticket_ref.id.clone().into(), "test-request") + .unwrap(); let Json(started) = scoped_start_workspace_orchestrator( State(api.clone()), @@ -51392,7 +51072,7 @@ mod tests { let candidates = backend .list(ticket::TicketListQuery::states([ - ticket::TicketListState::Queued, + ticket::TicketListState::Open, ])) .unwrap(); let mut truncated_candidates = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS @@ -51525,7 +51205,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); @@ -51581,7 +51261,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::InProgress + TicketState::Open ); let Json(replay) = scoped_create_runtime_worker( State(api), @@ -51645,7 +51325,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() @@ -51729,7 +51409,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())) @@ -51759,26 +51439,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.") ); } @@ -53336,7 +53023,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(); @@ -56271,7 +55958,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", @@ -56320,7 +56007,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", @@ -58825,7 +58512,7 @@ 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(); sqlite_store @@ -58991,7 +58678,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); @@ -61382,8 +61069,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 @@ -61877,7 +61564,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/tests/api_logging_tests.rs b/crates/workspace-server/src/server/tests/api_logging_tests.rs index 803f8392a..e4095b829 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:?}" 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..6a5e473bc 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,22 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev } } +// Exercise the generated public API through the router, then reread the real +// Ticket authority. No live Runtime, repository provider, or Worker process runs. +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(); @@ -738,84 +763,319 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re .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 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 = build_inner_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(); + assert_eq!( + closed.current_closure.as_ref().unwrap().author, + "workspace-user" + ); + 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 = build_inner_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..ae3340877 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,17 +446,6 @@ 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 { operation_id: "claim-runtime-reconciliation".into(), @@ -503,7 +491,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 +592,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 0a267d1b7..474e37895 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 @@ -288,17 +288,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![ @@ -382,24 +401,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 @@ -414,8 +449,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); @@ -434,7 +477,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(); @@ -510,7 +553,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 { @@ -532,21 +575,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 @@ -572,7 +629,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(); @@ -617,7 +674,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"}]); @@ -650,7 +707,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}"); @@ -703,7 +760,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", @@ -766,7 +823,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()) @@ -925,10 +982,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( @@ -987,8 +1044,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))?, @@ -1000,7 +1063,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}"); @@ -1236,7 +1299,110 @@ 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 orchestrator_interest_requires_queue_receipt_or_unfinished_work_not_just_open_state() { + 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!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open") + .unwrap() + .is_none() + ); + backend + .queue_ready(queued_id.clone().into(), "fixture") + .unwrap(); + assert!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open") + .unwrap() + .is_some() + ); + fixture.case_action( + &queued_id, + "queue-case-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + assert!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "closed") + .unwrap() + .is_none() + ); + fixture.case_action( + &queued_id, + "queue-case-reopen", + ticket::TicketCaseAction::Reopen, + ); + assert!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open") + .unwrap() + .is_none() + ); + + 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!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &active_id, "open") + .unwrap() + .is_some() + ); + 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!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &active_id, "open") + .unwrap() + .is_none() ); } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 3db49cc5d..a3f20242c 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 89; +const LATEST_SCHEMA_VERSION: i64 = 90; 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"; @@ -281,6 +281,11 @@ const MIGRATIONS: &[Migration] = &[ name: "descriptive legacy evidence columns", apply: migrate_evidence_column_names_v88_to_v89, }, + Migration { + version: 90, + name: "Ticket open/closed cases and retained routing evidence", + apply: migrate_ticket_cases_v89_to_v90, + }, ]; #[derive(Clone, Copy)] @@ -7935,10 +7940,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), ) @@ -8036,10 +8044,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![workspace_id, ticket_id], |row| row.get(0), )?; - if !matches!( - state.as_str(), - "planning" | "ready" | "queued" | "inprogress" - ) { + if state != "open" { return Err(Error::TicketAssignmentConflict(format!( "Ticket `{ticket_id}` cannot claim work in state `{state}`" ))); @@ -8094,6 +8099,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)?; @@ -8589,9 +8599,9 @@ 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( @@ -8608,7 +8618,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { (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')", + AND related.workflow_state = 'open'", params![record.workspace_id, record.ticket_id], |row| row.get(0), )?; @@ -8692,10 +8702,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), @@ -8711,17 +8721,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()) }) @@ -8773,17 +8773,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, @@ -8800,49 +8789,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) }) @@ -13512,11 +13465,8 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re TICKET_SCHEMA_BASELINE_NAME ], )?; - ticket::verify_sqlite_ticket_schema(db).map_err(|error| { - Error::Store(format!( - "schema-66 Ticket schema verification failed: {error}" - )) - })?; + // This retained step composes the historical Ticket schema 7. + // Current-domain verification follows the shared case migration at schema 90. let foreign_key_violations = db.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { @@ -13579,7 +13529,72 @@ 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 90).") + .ok_or_else(|| Error::Store("case assignment schema resource is missing".into()))?; + conn.execute_batch(sql)?; + Ok(()) +} + +fn migrate_ticket_cases_v89_to_v90(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 89 { + return Err(Error::Store( + "Ticket case migration requires schema 89".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 89 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 (90, '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 create_latest_workspace_schema(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, @@ -13656,7 +13671,10 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { )); "#, )?; - conn.execute_batch(include_str!("assignment_work.sql"))?; + install_ticket_case_assignment_work_schema(conn)?; + for sql in ticket_parent_triggers { + conn.execute_batch(&sql)?; + } ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { Error::Store(format!( "fresh Workspace Ticket schema verification failed: {error}" @@ -13715,6 +13733,45 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { } } } + if current_schema_version(conn)? >= 90 { + 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 90" + ))); + } + } + } Ok(()) } @@ -15935,7 +15992,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(); + // The retained step is Ticket-7; current verification runs after schema 90. assert!(!column_exists(&conn, "typed_tickets", "repository_id").unwrap()); assert!(!column_exists(&conn, "typed_tickets", "ref_selector").unwrap()); assert_eq!( @@ -16246,6 +16303,16 @@ 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_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, @@ -16323,11 +16390,7 @@ 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}" - )) - })?; + // Verification uses current schema 9 only after the real retained chain. Ok(()) } @@ -16650,6 +16713,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx version: 89, name: "descriptive legacy evidence columns".to_string() }, + WorkspaceSchemaMigrationStep { + version: 90, + name: "Ticket open/closed cases and retained routing evidence".to_string() + }, ] ); @@ -16749,6 +16816,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (87, "Repository SSH key identities and encrypted operation objects".to_string()), (88, "Runtime binding identities and retention content contracts".to_string()), (89, "descriptive legacy evidence columns".to_string()), +(90, "Ticket open/closed cases and retained routing evidence".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -19257,6 +19325,436 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); } + // SQLite boundary: a genuine retained Workspace-89 / Ticket-7 fixture, + // not a new database with a lower version marker. + fn legacy_case_workspace() -> (tempfile::TempDir, std::path::PathBuf) { + 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(); + (dir, path) + } + + fn stored_fixture_rows(conn: &Connection, table: &str) -> Vec> { + let mut stmt = conn + .prepare(&format!("SELECT * FROM {table} ORDER BY rowid")) + .unwrap(); + let columns = stmt.column_count(); + stmt.query_map([], |row| (0..columns).map(|i| row.get(i)).collect()) + .unwrap() + .collect::>() + .unwrap() + } + + #[test] + fn schema_v89_cases_preserve_all_six_states_evidence_and_responsibility_without_starting_workers() + { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(); + 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(); + let unchanged = [ + "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", + ]; + let before = unchanged.map(|table| stored_fixture_rows(&conn, table)); + drop(conn); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 90); + 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_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.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!( + 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() + ); + } + } + } + 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); + let reopened = SqliteWorkspaceStore::open(&path).unwrap(); + 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_v90_resumes_after_shared_ticket_migration_without_duplicating_judgments() { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(); + 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(); + let error = migrate_ticket_cases_v89_to_v90(&conn).unwrap_err(); + assert!(error.to_string().contains("DROP TABLE")); + assert_eq!(current_schema_version(&conn).unwrap(), 89); + // 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_v89_to_v90(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 90); + assert_eq!( + stored_fixture_rows(&conn, "typed_ticket_case_history"), + before + ); + drop(conn); + let store = SqliteWorkspaceStore::open(&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_v90_case_constraint_verification_rejects_legacy_view_predicates() { + let store = SqliteWorkspaceStore::in_memory().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 90")); + 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] @@ -19370,7 +19868,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(); @@ -19428,6 +19926,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) @@ -19458,8 +19981,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');")?; @@ -19530,8 +20053,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn pending_claims_fence_all_assignment_writers_and_worker_binding_until_owner_commits() { 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, @@ -19702,7 +20225,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(); @@ -19795,7 +20318,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 @@ -19810,7 +20333,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![], @@ -19834,7 +20363,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx 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 @@ -19862,7 +20392,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 @@ -19937,15 +20467,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); @@ -19957,7 +20480,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}"); @@ -19994,12 +20517,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(()) @@ -20012,8 +20535,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 @@ -20037,7 +20560,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx [], |row| row.get::<_, String>(0) )?, - "queued" + "open" ); assert_eq!( conn.query_row( @@ -20049,7 +20572,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(()) }) @@ -20152,12 +20675,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) @@ -20173,9 +20698,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) @@ -20202,9 +20730,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(); @@ -20250,9 +20783,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 @@ -20563,7 +21096,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()), @@ -20636,7 +21169,7 @@ INSERT INTO worker_registry ( .unwrap() .meta .workflow_state, - ticket::TicketWorkflowState::Ready + ticket::TicketState::Open ); assert!( store @@ -20672,10 +21205,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 @@ -20735,7 +21265,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 @@ -20850,7 +21380,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 ( @@ -20880,7 +21410,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 ( diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index df0250583..9c1f2a327 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -1266,9 +1266,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 ( @@ -1288,7 +1287,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( @@ -1307,59 +1306,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' @@ -1369,49 +1367,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/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index dfc1b70da..922c3334c 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -52,6 +52,9 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "ticket_current_worker_assignments", "ticket_worker_assignment_events", "ticket_worker_assignments", + "typed_ticket_case_history", + "typed_ticket_case_metadata", + "typed_ticket_legacy_workflow_snapshot", "typed_ticket_artifacts", "typed_ticket_event_attributes", "typed_ticket_event_references", @@ -457,6 +460,26 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { } let workspace_id = operation.response.workspace_id.clone(); + // Case judgments and legacy snapshots are immutable under ordinary + // Ticket 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 case_delete_guards = Vec::new(); + for (table, trigger) in [ + ("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}\";"))?; + case_delete_guards.push(definition); + } for table in WORKSPACE_DELETION_PURGE_TABLES { tx.execute( &format!("DELETE FROM \"{table}\" WHERE workspace_id = ?1"), @@ -472,6 +495,9 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { "Workspace disappeared before deletion finalized".to_string(), )); } + for definition in case_delete_guards { + tx.execute_batch(&definition)?; + } let now = Utc::now().to_rfc3339(); tx.execute( "UPDATE workspace_deletion_operations @@ -874,8 +900,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().expect("store")) + } + + fn seed_workspaces(store: SqliteWorkspaceStore) -> (SqliteWorkspaceStore, String, String) { store.with_conn(|conn| { let now = Utc::now().to_rfc3339(); conn.execute( @@ -931,6 +959,191 @@ 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(&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; + 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"] { + 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", + ]; + 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), + )?; + assert_eq!(count, 1, "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: String = 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/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 3f108629f..1f53b016c 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,540 @@ 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, + } + } + + 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 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/ticket-case-state.md b/docs/development/ticket-case-state.md new file mode 100644 index 000000000..85ee138bb --- /dev/null +++ b/docs/development/ticket-case-state.md @@ -0,0 +1,53 @@ +# Ticket案件状態・終了判断 + +Ticketの現行案件状態は `open` / `closed` のみ。`open` は引き続き扱う案件であり、着手可能・queue済みではない。`closed` は現時点で案件としての対応を終えた判断であり、要求実現済みとは限らない。Orchestratorの追跡、実作業拘束、Workerの稼働、MR証跡とは別の正本である。 + +## 判断の公開境界 + +通常Tool `TicketCaseUpdate` とWIPの `tickets/T-*/case` は、API `POST /workspaces/{workspace_id}/tickets/{ticket_id}/case` と同じ判断契約を使用する。旧complete/state/close専用判断APIは現行契約ではない。 + +- `Close { reason }`: openをclosedにする。 +- `Reopen`: closedをopenにし、現在の終了判断を外す。 +- `CorrectClosure { reason }`: closedの現在判断を訂正する。過去の判断は変更しない。 +- 理由は `completed` / `not_planned` / `duplicate` / `unknown` / `{ other: string }`。`completed` だけが要求実現済みを表す。すべての操作に説明を必要とし、任意のtyped成果参照を保持する。 + +`ShowTicket`/detailは現在の終了判断、最後の終了判断のoperation key、close/reopen/訂正のimmutable履歴とlegacy handoffを返す。query/listにも現在の判断と観測用key、handoffを公開する。再開後も過去の理由、説明、成果参照、判断主体、時刻を失わない。判断主体は認証済みBackend actorから設定し、公開API/LLM入力からの偽装を許可しない。案件のcloseにRepository/MR/第三者承認や固定段階の通過を一律要求しない。 + +## 更新競合と再送 + +更新前に次の三つを同じ判断transaction内で照合する。 + +1. `expected_content_digest`: 実際のeditable要件(identity/title/body/順序付きtargets)のSHA-256。threadや案件判断履歴はこのdigestを動かさない。 +2. `expected_state`: 観測した案件状態。 +3. `expected_closure_operation_key`: 観測した最後の終了判断のkey。未closeなら明示的なnullを送る。省略はunknownとして拒否する。 + +最後の終了判断のkeyはreopen後も残る。これにより、本文とopen状態が同じでもopen→closed→openを見落とさず、closedの訂正同士の競合も検知する。時刻や到着順だけではこの一致を証明できない。 + +operation keyのexact replayは、全入力と認証済みsource identityのfingerprintに一致する場合だけ受理する。古い操作を再適用せず、現在の案件を返す。異入力の同key再利用は拒否する。判断履歴とreceipt、案件状態、Workspaceの作業releaseは同じtransactionで扱い、hook失敗時も途中の判断を残さない。 + +## 担当・作業・資産との分離 + +closeは案件の実作業拘束を終える一方、T-715の終端担当責任を表示用に保持する。reopenから古い実行identity、claim、grantを復活させない。新しい実作業には明示的なrouting/claimが必要であり、open案件を自動queueと解釈しない。close/reopen/訂正そのものはWorker起動・停止・復元・削除、担当変更、資産grant、MR承認・統合を行わない。停止・削除のhold/pin/attachment等の独立guardは維持する。 + +## 旧保存データの移行 + +独立T-739 sourceではTicket component schema7→9、Workspace schema89→90を使用する。component6からの既存移行も維持する。 + +| 旧workflow state | 現行案件状態 | 終了理由 | +| --- | --- | --- | +| planning / ready / queued / inprogress | open | なし | +| done / closed | closed | unknown | + +旧doneを成功の証拠に昇格しない。移行による終了判断はmigration actorを明示し、旧resolutionの説明を保持する。旧thread、completion/close receipt、担当履歴、MR証跡を改ざんしない。旧phase、explicit flag、status、queued_by/queued_at、resolutionをimmutable `typed_ticket_legacy_workflow_snapshot` に保存し、`legacy_handoff` として後続追跡/routing移行へ渡す。これは現行案件状態の第二正本ではない。移行だけで過去のWorkerや終了済み作業を起動しない。 + +component schema8は並行T-740のタグ契約が所有するため、この独立sourceは8を推測して9へ変換せず安全に拒否する。T-740との統合時には、タグschemaと案件schemaの明示的な合成、7およびタグあり8からの移行、再実行、タグ/監査/終了履歴保持を検証する必要がある。番号を分けただけでは合成完了と扱わない。 + +## 検証境界 + +- `crates/ticket/tests/case.rs`: Repository/MRなしの理由、再開/訂正、actor/ref/history、CAS/ABA、exact replay/異入力拒否、並行close、Workspace境界、transaction rollback。 +- `crates/ticket/tests/migration.rs`: 六状態、旧thread/receipt/担当/MR参照の保持、理由不明、再open、schema8の安全な拒否。 +- Workspace Store: 六状態×MRあり/なし、終端担当、旧queue/handoff、migration resumption、Workerを起動しない移行。 +- Workspace API/authority/lifecycle: 偽装actor・無権限・他Workspace・旧route拒否、作業releaseと表示責任、reopen時の新routing identity、独立削除guardとWorkspace purge rollback。 +- Worker通常Tool/WIP、Client、CLI、Web parser: 同じcase入力、モデルに見えるnullable観測keyと履歴、Backend以外のfallback禁止、stale失敗の伝達。 + +実経路統合・Orchestrator追跡の置換は後続単位の責務であり、以上の単独source検証をT-740合成や実環境更新の検証と混同しない。 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/openapi/server-api.json b/openapi/server-api.json index 53424c5a8..3d32fa641 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -298,30 +298,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 +387,13 @@ "additionalProperties": false, "type": "object" }, + "BrowserTicketState": { + "enum": [ + "open", + "closed" + ], + "type": "string" + }, "BrowserTicketTargetEdit": { "oneOf": [ { @@ -457,51 +440,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 +1029,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": { @@ -5638,7 +5545,7 @@ "workflow_state": { "anyOf": [ { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState" }, { "type": "null" @@ -10598,6 +10505,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" }, @@ -10607,6 +10537,16 @@ }, "type": "array" }, + "legacy_workflow_snapshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketLegacyWorkflowSnapshot" + }, + { + "type": "null" + } + ] + }, "meta": { "$ref": "#/components/schemas/TicketMeta" }, @@ -10629,7 +10569,8 @@ "document", "events", "artifacts", - "relations" + "relations", + "case_history" ], "type": "object" }, @@ -10777,6 +10718,19 @@ ], "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": { @@ -10796,12 +10750,105 @@ ], "type": "object" }, - "TicketCompletion": { - "additionalProperties": false, - "description": "Audited completion independent of repositories, Git, or Merge Requests.", + "TicketCaseAction": { + "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" @@ -10811,12 +10858,12 @@ "type": "string" }, "expected_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState" }, - "operation_key": { + "explanation": { "type": "string" }, - "reason": { + "operation_key": { "type": "string" }, "references": { @@ -10830,11 +10877,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.\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": { @@ -10904,6 +11013,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" }, @@ -10913,6 +11035,16 @@ "null" ] }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "current_worker": { "anyOf": [ { @@ -10950,6 +11082,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" @@ -11015,7 +11158,7 @@ "type": "array" }, "state": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" }, "targets": { "items": { @@ -11057,7 +11200,8 @@ "action_eligibility", "merge_requests", "evidence", - "record_source" + "record_source", + "closure_history" ], "type": "object" }, @@ -11696,6 +11840,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": { @@ -11726,9 +11908,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" }, @@ -11751,7 +11961,7 @@ "type": "string" }, "state": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" }, "title": { "type": "string" @@ -12014,7 +12224,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" @@ -12070,6 +12281,13 @@ "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" }, @@ -12079,6 +12297,16 @@ "null" ] }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "current_worker": { "anyOf": [ { @@ -12095,6 +12323,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" @@ -12164,7 +12403,7 @@ ] }, "state": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" }, "title": { "type": "string" @@ -12217,7 +12456,7 @@ "attention": { "default": [], "items": { - "type": "string" + "$ref": "#/components/schemas/TicketAttention" }, "type": "array" }, @@ -12289,7 +12528,7 @@ "states": { "default": [], "items": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" }, "type": "array" }, @@ -12449,7 +12688,7 @@ "TicketRelationBlocker": { "properties": { "blocking_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState" }, "blocking_ticket": { "type": "string" @@ -12762,48 +13001,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": [ @@ -12869,7 +13072,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" @@ -12994,17 +13198,6 @@ ], "type": "object" }, - "TicketWorkflowState": { - "enum": [ - "planning", - "ready", - "queued", - "in_progress", - "done", - "closed" - ], - "type": "string" - }, "TicketWorkspaceNextAction": { "enum": [ "clarify", @@ -16135,7 +16328,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3" + "x-yoi-source-digest": "sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e" }, "openapi": "3.1.0", "paths": { @@ -34048,260 +34241,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", @@ -35744,510 +35683,6 @@ ] } }, - "/api/w/{workspace_id}/tickets/{id}/state": { - "post": { - "operationId": "ticket_state_transition", - "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/BrowserTransitionTicketStateRequest" - } - } - }, - "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}/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": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, "/api/w/{workspace_id}/tickets/{id}/thread-events": { "post": { "operationId": "ticket_thread_event_add", @@ -36358,246 +35793,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", @@ -38876,6 +38071,133 @@ } } } + }, + "/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/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..37b703801 100644 --- a/resources/prompts/common/tickets.md +++ b/resources/prompts/common/tickets.md @@ -8,6 +8,6 @@ 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. diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index 927d9fa9d..b6f634ffa 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -1,4 +1,4 @@ -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. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index 84b5fe42f..31f88c771 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -4,7 +4,7 @@ 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. +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. 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. After start, reread the Ticket and verify the new 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 +20,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..111160a50 100644 --- a/resources/prompts/role/ticket-worker.md +++ b/resources/prompts/role/ticket-worker.md @@ -2,6 +2,6 @@ You are the assigned Ticket Worker. Carry out the user's requested work and keep 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. -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 53ae6cd1d..5853c109c 100644 --- a/tools/state-contract-name-exceptions.json +++ b/tools/state-contract-name-exceptions.json @@ -559,9 +559,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", diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 5cc5000c9..5a25ed37b 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/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 a03341919..101b80385 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // 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 93145145c..e8a9fa21f 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // 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 388f6867b..bd8dadd0f 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // 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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 8818a7ad0..8afefad5e 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,26 +1,22 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // 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 BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; }; - 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 +25,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 +55,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; }; @@ -91,7 +89,17 @@ export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contri 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 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>; 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; 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 +119,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; 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; }; @@ -133,12 +143,14 @@ 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; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; }; + 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"; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index b40098ed6..d4819b6f9 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:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // 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 8814b40e8..d48cb23e8 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:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // 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 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..000a066c5 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -48,7 +48,7 @@ Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => id: "ticket-id", resource_key: "T-1", title: "Ticket", - state: "ready", + state: "open", priority: "P2", updated_at: null, queued_by: null, @@ -105,16 +105,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() { @@ -131,6 +138,10 @@ function ticketDetailFixture() { artifacts: [], assignment_diagnostics: [], assignments: [], + closure_history: [], + closure_operation_key: null, + current_closure: null, + legacy_handoff: null, body: "Body", body_truncated: false, current_worker: null, @@ -164,7 +175,7 @@ function ticketDetailFixture() { resolution: null, resource_key: "T-1", risk_flags: [], - state: "planning", + state: "open", targets: [ { repository_key: "docs", @@ -342,11 +353,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 +365,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 +451,135 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread", "unknown field unexpected", ); }); + +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("case projections retain reasons, actors, references and migration history after reopening", () => { + const fixture = { + ...ticketDetailFixture(), + state: "open", + 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( + parseTicketDetail({ ...fixture, state: "closed", current_closure: closure }) + .current_closure, + closure, + ); + 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", + priority: "P2", + record_source: "sqlite", + workspace_action_priority: "background", + closure_operation_key: "close-1", + current_closure: closure, + legacy_handoff: legacyHandoff, + }], + }); + assertEquals(list.items[0].current_closure, closure); + assertEquals(list.items[0].legacy_handoff, legacyHandoff); +}); + +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..95bc699f9 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, @@ -20,6 +21,10 @@ import type { TicketActionEligibility, TicketAssignmentPrincipal, TicketAssignmentSummary, + TicketCaseAction, + TicketCaseHistoryEntry, + TicketClosureJudgment, + TicketClosureReason, TicketDetail, TicketDetailDerivedRelation, TicketDetailRelation, @@ -29,14 +34,17 @@ import type { TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary, + TicketLegacyWorkflowSnapshot, TicketListItemSummary, TicketListResponse, TicketMergeRequestSummary, TicketQueueOutcome, TicketRef, + TicketReference, TicketRoleAssignmentMutationResponse, TicketRoleAssignmentRecord, TicketRoleAssignmentSummary, + TicketSummary, TicketTarget, } from "#lib/generated/ticket-api.ts"; @@ -183,12 +191,208 @@ 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 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", + "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, + 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", @@ -201,13 +405,14 @@ function parseTicketListItem( "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`), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), workspace_action_priority: string( @@ -727,6 +932,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { "artifacts", "assignment_diagnostics", "assignments", + "closure_history", "body", "body_truncated", "created_at", @@ -735,6 +941,9 @@ export function parseTicketDetail(value: unknown): TicketDetail { "event_page", "events", "evidence", + "closure_operation_key", + "current_closure", + "legacy_handoff", "id", "implementation_reports", "content_digest", @@ -756,6 +965,12 @@ export function parseTicketDetail(value: unknown): TicketDetail { "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`, @@ -820,7 +1035,7 @@ 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`), targets: array(item.targets, `${label}.targets`, parseTicketTarget), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), 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..1cb7d2601 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-case.test.ts @@ -0,0 +1,116 @@ +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: [], + 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)); + }; + assertEquals( + (await updateTicketCase("workspace one", "T/1", request)) + .workflow_state, + "closed", + ); + } + } 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/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 45cb8ed87..df1dc3d0f 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 @@ -404,12 +404,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as "Tickets list should fetch lightweight paginated summaries for each Kanban lane", ); 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,7 +427,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as ); assert( ticketDetailLoad.includes("/repositories") && - ticketDetailPage.includes('mutate("state", "/state"') && + ticketDetailPage.includes("ticketCaseRequest(ticket, action") && ticketDetailPage.includes("async function queueTicket") && ticketDetailPage.includes("const path = ticketPath") && ticketDetailPage.includes("`${path}/queue`") && @@ -436,7 +435,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as ticketDetailPage.includes("mergeRequest.selector_from") && ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") && ticketDetailPage.includes("mergeRequestPagePath") && - ticketDetailPage.includes('mutate("close", "/close"') && + ticketDetailPage.includes("await updateTicketCase(") && 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..242695ef2 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,35 @@ 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, 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, @@ -87,7 +93,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 +114,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..b50df4bf2 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", @@ -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/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index 63dd10a71..e472b4076 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -23,56 +23,26 @@ 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", updated_at: "2026-07-29T12:00:00Z", }, { - id: "ready-new", - state: "ready", - title: "Ready new", + id: "new", + state: "open", + title: "Open new", updated_at: "2026-07-30T12:00:00Z", }, - { id: "queued", state: "queued", title: "Queued", updated_at: null }, - { - id: "inprogress", - state: "inprogress", - title: "In progress", - 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", () => { diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index fd246e20e..1a2a388d4 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -4,35 +4,12 @@ import type { TicketListItemSummary, } 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; @@ -44,15 +21,6 @@ export type TicketCardSummary = Pick< "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 TicketLane = { id: TicketLaneId; label: string; @@ -77,9 +45,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); 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]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index 53b080f00..383828e4a 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -168,7 +168,7 @@ labels: [], body: createBody, risk_flags: [], - workflow_state: "planning", + workflow_state: "open", targets: normalizedCreateTargets(), }; try { @@ -297,7 +297,7 @@ </p> {:else if !orchestrator.data?.online} <p class="workspace-callout"> - Orchestrator is offline. Requests can be queued; progress and results can still be recorded directly. + Orchestrator is offline. Requests can be queued; case decisions and results can still be recorded directly. </p> {/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts index b94d509c7..9721b4e1d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts @@ -9,11 +9,7 @@ import type { TicketListResponse } from "#lib/generated/ticket-api.ts"; import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts"; import type { PageLoad } from "./$types"; -const LANE_STATES = { - "ready-planning": ["ready", "planning"], - "inprogress-queued": ["inprogress", "queued"], - "done-closed": ["done", "closed"], -} as const; +const LANE_STATES = { open: ["open"], closed: ["closed"] } as const; export type TicketLaneId = keyof typeof LANE_STATES; @@ -48,15 +44,13 @@ async function loadLane( export const load: PageLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; const [ - readyPlanning, - inprogressQueued, - doneClosed, + open, + closed, orchestrator, repositories, ] = await Promise.all([ - loadLane(fetch, workspaceId, "ready-planning"), - loadLane(fetch, workspaceId, "inprogress-queued"), - loadLane(fetch, workspaceId, "done-closed"), + loadLane(fetch, workspaceId, "open"), + loadLane(fetch, workspaceId, "closed"), loadJson<WorkspaceOrchestratorStatus>( fetch, workspaceApiPath(workspaceId, "/orchestrator"), @@ -69,9 +63,8 @@ export const load: PageLoad = async ({ fetch, params }) => { return { workspaceId, ticketLanes: { - "ready-planning": readyPlanning, - "inprogress-queued": inprogressQueued, - "done-closed": doneClosed, + open, + closed, }, orchestrator, repositories, diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index af878eaf7..b2536cc0c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -12,16 +12,18 @@ parseTicketQueueOutcome, parseTicketRoleAssignmentMutationResponse, } from "#lib/workspace/api/ticket-browser.ts"; + import { ticketCaseRequest, updateTicketCase } from "#lib/workspace/api/ticket-case.ts"; import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts"; import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts"; import { relationLabel, - TICKET_STATES, ticketWorkerLaunchHref, type WorkspaceOrchestratorStatus, } from "#lib/workspace/tickets/ticket-panel.ts"; import type { ApiResult } from "#lib/workspace/api/http.ts"; import type { + TicketCaseAction, + TicketClosureReason, TicketTarget, TicketTargetAccess, } from "#lib/generated/ticket-api.ts"; @@ -37,7 +39,6 @@ access: TicketTargetAccess; }; - const MUTABLE_TICKET_STATES = TICKET_STATES; function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] { return targets.map((target) => ({ @@ -78,12 +79,11 @@ let targetDrafts = $state<EditableTicketTarget[]>( editableTargets(loadedTicket.targets), ); - let nextState = $state(loadedTicket.state); let transitionReason = $state(""); - let progressBody = $state(""); + let closureReason = $state("unknown"); + let otherClosureReason = $state(""); let threadRole = $state("comment"); let threadBody = $state(""); - let resolution = $state(""); let busy = $state<string | null>(null); let errorMessage = $state<string | null>(null); let queueMessage = $state<string | null>(null); @@ -139,17 +139,16 @@ editTitle = updatedTicket.title; editBody = updatedTicket.body; targetDrafts = editableTargets(updatedTicket.targets); - nextState = updatedTicket.state; } function resetTicketView(updatedTicket: TicketDetail): void { applyTicket(updatedTicket); editing = false; transitionReason = ""; - progressBody = ""; + closureReason = "unknown"; + otherClosureReason = ""; threadRole = "comment"; threadBody = ""; - resolution = ""; busy = null; errorMessage = null; queueMessage = null; @@ -363,7 +362,7 @@ } async function markReady() { - if (ticket.state !== "planning" || busy) return; + if (ticket.state !== "open" || busy) return; readyOperationKey ??= crypto.randomUUID(); if ( await mutate("ready", "/ready", { @@ -376,18 +375,33 @@ } } - async function transition(event: SubmitEvent) { - event.preventDefault(); - if ( - await mutate("state", "/state", { - state: nextState, - operation_key: crypto.randomUUID(), - expected_content_digest: ticket.content_digest, - expected_state: ticket.state, - reason: transitionReason.trim(), - body: progressBody.trim() || null, - }) - ) { transitionReason = ""; progressBody = ""; } + function selectedClosureReason(): TicketClosureReason { + return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason; + } + + async function caseDecision(action: TicketCaseAction): Promise<void> { + if (busy || !transitionReason.trim()) return; + const generation = routeGeneration; + const path = ticketPath; + const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID()); + busy = action.action; + errorMessage = null; + try { + await updateTicketCase(data.workspaceId, data.ticketId, request); + if (generation !== routeGeneration) return; + const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + if (generation !== routeGeneration) return; + applyTicket(updated); + transitionReason = ""; + } catch (error) { + if (generation === routeGeneration) errorMessage = ticketMutationError(error); + } finally { + if (generation === routeGeneration) busy = null; + } + } + + function closureReasonLabel(reason: TicketClosureReason): string { + return typeof reason === "string" ? relationLabel(reason) : reason.other; } async function appendThread(event: SubmitEvent) { @@ -401,19 +415,6 @@ ) threadBody = ""; } - async function closeTicket(event: SubmitEvent) { - event.preventDefault(); - if (!resolution.trim()) return; - if ( - await mutate("close", "/close", { - resolution: resolution.trim(), - operation_key: crypto.randomUUID(), - expected_content_digest: ticket.content_digest, - expected_state: ticket.state, - }) - ) resolution = ""; - } - function eventTitle(kind: string): string { return relationLabel(kind); } @@ -550,22 +551,29 @@ <aside class="ticket-control-rail"> <section class="ticket-control-card"> - <header><h2>Progress decision</h2></header> - <p class="workspace-empty-copy">State records progress only. It does not start or stop a Worker, grant access, approve review, or merge code. Reopening does not resume an old Worker.</p> - <form class="ticket-control-form" onsubmit={transition}> - <label>State - <select bind:value={nextState}> - {#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each} + <header><h2>Case decision</h2></header> + <p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p> + <form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}> + <label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label> + <label>Closure reason + <select bind:value={closureReason}> + <option value="completed">Completed</option> + <option value="not_planned">Not planned</option> + <option value="duplicate">Duplicate</option> + <option value="unknown">Unknown</option> + <option value="other">Other</option> </select> </label> - <label>Reason<input bind:value={transitionReason} placeholder="Completion, start, or reopening decision" required /></label> - <label>Result, references, and remaining work (optional)<textarea bind:value={progressBody} rows="3" placeholder="A comment can be the result. Add links and remaining questions here."></textarea></label> - <button class="workspace-secondary-button" type="submit" disabled={busy !== null || nextState === ticket.state || !transitionReason.trim()}> - {nextState === "done" ? "Complete Ticket" : ticket.state === "done" || ticket.state === "closed" ? "Reopen / apply state" : "Apply state"} + {#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if} + <button class="workspace-secondary-button" type="submit" disabled={busy !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}> + {ticket.state === "open" ? "Close ticket" : "Reopen ticket"} </button> + {#if ticket.state === "closed" && ticket.current_closure} + <button class="workspace-secondary-button" type="button" disabled={busy !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button> + {/if} </form> - {#if ticket.state === "planning"} - <button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Marking ready…" : "Mark ready"}</button> + {#if ticket.state === "open"} + <button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Recording readiness…" : "Record routing readiness"}</button> {/if} <button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>{busy === "queue" ? "Queueing…" : "Request Orchestrator (queue)"}</button> <p class="workspace-empty-copy">Queue requests only this Ticket. Dependencies remain diagnostic information and are not automatically queued.</p> @@ -631,12 +639,12 @@ </button> </form></details> {/if} - {#if ticket.state === "inprogress" && workerAssignment} + {#if workerAssignment} <details class="ticket-cancel-implementation"> <summary>Cancel implementation</summary> <form class="ticket-control-form" onsubmit={cancelImplementation}> <p class="workspace-empty-copy"> - Cancel the assigned Worker’s unfinished work and return this Ticket to ready. Responsibility is retained. This does not delete the Worker or Workdir. + Cancel the assigned Worker’s unfinished work without changing the case state. Responsibility is retained. This does not delete the Worker or Workdir. </p> <label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label> <button @@ -644,7 +652,7 @@ type="submit" disabled={busy !== null || !cancellationReason.trim()} > - {busy === "cancel-implementation" ? "Cancelling…" : "Cancel and return to ready"} + {busy === "cancel-implementation" ? "Cancelling…" : "Cancel unfinished work"} </button> </form> </details> @@ -724,16 +732,16 @@ {#each ticket.evidence.missing as missing}<li>{missing}</li>{/each} </ul> {/if} - <p class="workspace-empty-copy">This evaluates linked MR requirements, separately from Ticket progress and persisted integration approval.</p> - {#if ticket.state === "done"} - <p class="workspace-empty-copy">This Ticket remains done. Incomplete current requirement evidence does not cancel recorded completion.</p> + <p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p> + {#if ticket.state === "closed"} + <p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p> {/if} </section> {/if} <section class="ticket-control-card"> <header><h2>Merge Requests</h2></header> - <p class="workspace-empty-copy">Ticket completion is not MR approval or integration.</p> + <p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p> {#if mergeRequests.length > 0} {#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)} <article class="ticket-control-card"> @@ -759,20 +767,33 @@ </article> {/each} {:else} - <p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be completed with a comment or document result.</p> + <p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be closed with a comment or document result.</p> {/if} </section> - {#if ticket.state !== "closed"} - <details class="ticket-control-card ticket-close-card"> - <summary>Close ticket</summary> - <form class="ticket-control-form" onsubmit={closeTicket}> - <label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label> - <button class="workspace-danger-button" type="submit" disabled={busy !== null || !resolution.trim()}>Close ticket</button> - </form> + {#if ticket.current_closure} + <section class="ticket-control-card"><header><h2>Current closure</h2></header> + <p>{closureReasonLabel(ticket.current_closure.reason)} · {ticket.current_closure.author} · {prettyDate(ticket.current_closure.at)}</p> + <RichMarkdown text={ticket.current_closure.explanation} /> + {#each ticket.current_closure.references as reference}<p>{reference.kind}: {reference.target}</p>{/each} + </section> + {/if} + {#if ticket.closure_history.length > 0} + <details class="ticket-control-card"><summary>Case history ({ticket.closure_history.length})</summary> + {#each ticket.closure_history as judgment (judgment.operation_key)} + <article> + <p>{relationLabel(judgment.action.action)}{judgment.action.action !== "reopen" ? `: ${closureReasonLabel(judgment.action.reason)}` : ""} · {judgment.author} · {prettyDate(judgment.at)}</p> + <RichMarkdown text={judgment.explanation} /> + {#each judgment.references as reference}<p>{reference.kind}: {reference.target}</p>{/each} + </article> + {/each} + </details> + {/if} + {#if ticket.legacy_handoff} + <details class="ticket-control-card"><summary>Legacy routing handoff</summary> + <p>Historical state: {ticket.legacy_handoff.workflow_state}</p> + {#if ticket.legacy_handoff.resolution}<RichMarkdown text={ticket.legacy_handoff.resolution} />{/if} </details> - {:else if ticket.resolution} - <section class="ticket-control-card"><header><h2>Resolution</h2></header><RichMarkdown text={ticket.resolution} /></section> {/if} </aside> </div> diff --git a/web/workspace/test/ticket-detail-route-reuse.test.ts b/web/workspace/test/ticket-detail-route-reuse.test.ts index cdeab0e06..0c515fb92 100644 --- a/web/workspace/test/ticket-detail-route-reuse.test.ts +++ b/web/workspace/test/ticket-detail-route-reuse.test.ts @@ -30,6 +30,7 @@ Deno.test("ticket detail fences stale mutation responses", () => { const operation of [ "async function mutate(", "async function queueTicket(", + "async function caseDecision(", "async function mutateAssignment(", ] ) { diff --git a/web/workspace/vite.config.ts b/web/workspace/vite.config.ts index 594d31f07..574fddd09 100644 --- a/web/workspace/vite.config.ts +++ b/web/workspace/vite.config.ts @@ -40,6 +40,11 @@ export default defineConfig({ }, proxy: { + // Case judgments use the canonical authenticated Workspace API route. + "/workspaces": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, "/api": { target: "http://127.0.0.1:8787", changeOrigin: true, From 7f03db9d7959730ce4d329a18edb9dc0c9a8df8f Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Sun, 11 Oct 2026 03:36:27 +0900 Subject: [PATCH 04/39] Preserve verified case judgment identity and canonical route authentication --- crates/workspace-server/src/server.rs | 121 ++- .../src/server/tests/ticket_evidence_tests.rs | 25 +- .../src/server_ticket_case_signed_tests.rs | 758 ++++++++++++++++++ docs/development/ticket-case-state.md | 2 +- 4 files changed, 894 insertions(+), 12 deletions(-) create mode 100644 crates/workspace-server/src/server_ticket_case_signed_tests.rs diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 13b336831..d391d9f33 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2804,7 +2804,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/") + && ticket_case_workspace_id(request.uri().path()).is_none() + { return next.run(request).await; } let request_path = request.uri().path().to_owned(); @@ -3024,7 +3026,9 @@ async fn dispatch_workspace_request( None }; 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/") + || ticket_case_workspace_id(&path).is_some()) && let Err(response) = authorize_scoped_workspace_request(&api, workspace_id, &mut request).await { @@ -3098,7 +3102,26 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp static_file_or_spa_response(static_root, path).await } +// The canonical case route is deliberately not an /api/w alias. Recognize only +// this operation so its authentication/scope is identical without broadening CRUD. +fn ticket_case_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() + && operation == "case" + && segments.next().is_none()) + .then_some(workspace_id) +} + fn scoped_workspace_id(path: &str) -> Option<&str> { + if let Some(workspace_id) = ticket_case_workspace_id(path) { + return Some(workspace_id); + } if let Some(rest) = path.strip_prefix("/api/runtime/v1/workspaces/") { return rest .split('/') @@ -10279,9 +10302,10 @@ impl server_api::ServerApi for ServerApiContractService { ticket_id: String, request: server_api::TicketCaseUpdateRequest, ) -> std::result::Result<server_api::TicketSummary, server_api::RepositoryApiError> { - let Json(updated) = scoped_ticket_case_update( + let Json(updated) = scoped_ticket_case_update_with_context( State(self.workspace_api()?.clone()), contract_request_headers(&context)?, + &context, AxumPath((workspace_id, ticket_id)), Json(ticket::TicketCaseUpdate { expected_content_digest: request.expected_content_digest, @@ -14450,9 +14474,15 @@ async fn execute_ticket_rest_operation_with_context( ) -> ApiResult<TicketBackendOperationResult> { validate_workspace_scope(api, workspace_id)?; if let TicketBackendOperation::UpdateCase { ticket, request } = operation { - let Json(updated) = scoped_ticket_case_update( + 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), ) @@ -16466,9 +16496,74 @@ async fn scoped_complete_merge_request( .map_err(Into::into) } +// 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<WorkspaceApi>, headers: HeaderMap, + path: AxumPath<(String, String)>, + request: Json<ticket::TicketCaseUpdate>, +) -> ApiResult<Json<ticket::Ticket>> { + 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_case_update_with_context( + State(api): State<WorkspaceApi>, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + path: AxumPath<(String, String)>, + request: Json<ticket::TicketCaseUpdate>, +) -> ApiResult<Json<ticket::Ticket>> { + // 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(), + ) + })?; + Some(RuntimeWorkerRef::new(&source.runtime_id, worker_id)) + } else { + context + .worker_source + .as_ref() + .map(|source| RuntimeWorkerRef::new(&source.runtime_id, &source.worker_id)) + }; + 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()); + } + } + if source.is_none() && context.actor.is_none() { + return Err(Error::WorkspacePermissionDenied( + "Ticket case decisions require an authenticated actor".into(), + ) + .into()); + } + apply_ticket_case_update(State(api), source, context.actor.as_ref(), path, request).await +} + +async fn apply_ticket_case_update( + State(api): State<WorkspaceApi>, + source: Option<WorkerMutationSource>, + actor: Option<&RequestActor>, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(mut request): Json<ticket::TicketCaseUpdate>, ) -> ApiResult<Json<ticket::Ticket>> { @@ -16478,7 +16573,7 @@ async fn scoped_ticket_case_update( 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()) @@ -16509,7 +16604,19 @@ async fn scoped_ticket_case_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 @@ -32381,6 +32488,10 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { + mod ticket_case_signed_tests { + include!("server_ticket_case_signed_tests.rs"); + } + mod ticket_tags_tests { include!("server_ticket_tags_tests.rs"); } 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 6a5e473bc..f50cc7e95 100644 --- a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs @@ -739,8 +739,13 @@ async fn mrless_completed_closure_is_a_ticket_judgment_not_approved_or_missing_m } } -// Exercise the generated public API through the router, then reread the real -// Ticket authority. No live Runtime, repository provider, or Worker process runs. +// 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, @@ -762,7 +767,7 @@ async fn case_route_rejects_missing_cas_client_author_and_removed_workflow_route .create(ticket::NewTicket::new("Transport decisions")) .unwrap(); let before = backend.show(reference.id.clone().into()).unwrap(); - let app = build_inner_router(api); + let app = case_actor_router(api); let path = format!( "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", reference.id @@ -863,7 +868,7 @@ async fn case_route_preserves_corrected_history_replay_and_reopen_aba_guard_with .unwrap(); let before = backend.show(reference.id.clone().into()).unwrap(); let digest = ticket::ticket_content_digest(&before); - let app = build_inner_router(api); + let app = case_actor_router(api); let path = format!( "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", reference.id @@ -885,10 +890,18 @@ async fn case_route_preserves_corrected_history_replay_and_reopen_aba_guard_with 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, - "workspace-user" + 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 @@ -1031,7 +1044,7 @@ async fn case_route_rejects_stale_content_state_and_invalid_actions_without_audi .create(ticket::NewTicket::new("CAS decision")) .unwrap(); let before = backend.show(reference.id.clone().into()).unwrap(); - let app = build_inner_router(api); + let app = case_actor_router(api); let path = format!( "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", reference.id 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..78ba317b2 --- /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<Body>, + 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<Body> { + 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<Vec<Vec<rusqlite::types::Value>>> { + 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::<rusqlite::Result<Vec<_>>>() + }) + .unwrap() + .collect::<rusqlite::Result<Vec<_>>>() + .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<WorkspaceApi>, + axum::Extension(context): axum::Extension<server_api::ServerRequestContext>, + AxumPath((workspace, ticket)): AxumPath<(String, String)>, + headers: HeaderMap, + Json(request): Json<ticket::TicketCaseUpdate>, + ) -> ApiResult<Json<ticket::Ticket>> { + 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(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/docs/development/ticket-case-state.md b/docs/development/ticket-case-state.md index c1f7dbbd1..11df90b83 100644 --- a/docs/development/ticket-case-state.md +++ b/docs/development/ticket-case-state.md @@ -11,7 +11,7 @@ Ticketの現行案件状態は `open` / `closed` のみ。`open` は引き続き - `CorrectClosure { reason }`: closedの現在判断を訂正する。過去の判断は変更しない。 - 理由は `completed` / `not_planned` / `duplicate` / `unknown` / `{ other: string }`。`completed` だけが要求実現済みを表す。すべての操作に説明を必要とし、任意のtyped成果参照を保持する。 -`ShowTicket`/detailは現在の終了判断、最後の終了判断のoperation key、close/reopen/訂正のimmutable履歴とlegacy handoffを返す。query/listにも現在の判断と観測用key、handoffを公開する。再開後も過去の理由、説明、成果参照、判断主体、時刻を失わない。判断主体は認証済みBackend actorから設定し、公開API/LLM入力からの偽装を許可しない。案件のcloseにRepository/MR/第三者承認や固定段階の通過を一律要求しない。 +`ShowTicket`/detailは現在の終了判断、最後の終了判断のoperation key、close/reopen/訂正のimmutable履歴とlegacy handoffを返す。query/listにも現在の判断と観測用key、handoffを公開する。再開後も過去の理由、説明、成果参照、判断主体、時刻を失わない。判断主体は認証済みBackend actorから設定し、公開API/LLM入力からの偽装を許可しない。Runtime Workerのidentityは検証済みHTTP contextを正本とし、任意のsource hint headersがなくても失わない。hintがある場合は検証済みidentityとの一致を確認し、Worker identityのないRuntime-only要求を人間の判断として扱わない。人間の操作も認証済みaccount/user identityを判断主体・再送に結び付ける。案件のcloseにRepository/MR/第三者承認や固定段階の通過を一律要求しない。 ## 更新競合と再送 From 98023b9872b6699ef7afa6dfa260c03fa01db313 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 01:50:30 +0900 Subject: [PATCH 05/39] refactor: remove Workdir cleanup content hashing --- .../worker-runtime/src/working_directory.rs | 25 +- .../src/working_directory/cleanup.rs | 853 +++++++++++++++--- docs/development/workdir-removal.md | 91 +- 3 files changed, 825 insertions(+), 144 deletions(-) diff --git a/crates/worker-runtime/src/working_directory.rs b/crates/worker-runtime/src/working_directory.rs index 956d1c181..10d94be1e 100644 --- a/crates/worker-runtime/src/working_directory.rs +++ b/crates/worker-runtime/src/working_directory.rs @@ -741,22 +741,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<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> { - // 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<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> { let working_directory_root = self.working_directory_root(working_directory_id); let path = working_directory_root.join(MATERIALIZATION_RECORD); @@ -793,7 +783,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() @@ -4783,9 +4773,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 e13906edf..cf155e917 100644 --- a/crates/worker-runtime/src/working_directory/cleanup.rs +++ b/crates/worker-runtime/src/working_directory/cleanup.rs @@ -1,8 +1,8 @@ -//! Ordinary removal authority, not a recovery lifecycle. The content witness is -//! outside the tree being removed so a crash/partial unlink cannot erase it. -//! Every retry checks current identities, mounts and remaining content. Missing -//! entries are expected after partial deletion. The same intact clean checkout -//! may renew its witness after owner recovery, never discarding new ignored content. +//! Ordinary removal authority, not a recovery lifecycle. Durable entry metadata +//! outside the removed tree permits a surviving subset after partial unlink. +//! No ordinary file body is read by deletion. Git protects a usable checkout; +//! identity/type/size/mtime protect other entries, not byte-for-byte equality. +//! Directory timestamps are excluded because our own unlink changes them. use super::*; use std::io; @@ -30,7 +30,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) @@ -77,7 +77,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()); @@ -165,7 +165,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| { @@ -174,28 +174,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)?; @@ -211,9 +213,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; @@ -225,26 +227,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 @@ -263,6 +261,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<WorkingDirectoryBinding, WorkingDirectoryDiagnostic> { + // 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<PathBuf, Entry>) -> bool { entries.iter().all(|(path, entry)| { @@ -293,21 +342,36 @@ fn retry_inventory( saved: &Authority, ) -> Result<BTreeMap<PathBuf, Entry>, 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))?; @@ -318,7 +382,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) @@ -345,6 +409,8 @@ fn check_clean_checkout( } } let run = |args: &[&str]| -> Result<Vec<u8>, 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 @@ -381,14 +447,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")); + } } } } @@ -453,7 +525,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 @@ -461,7 +543,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")); } @@ -473,7 +554,60 @@ pub(super) fn validate_binding( struct Authority { working_directory: WorkingDirectory, root_identity: (u64, u64), + #[serde(rename = "entry_metadata")] entries: BTreeMap<PathBuf, Entry>, + #[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<PathBuf, LegacyEntry>, +} + +#[cfg(target_os = "linux")] +#[derive(Deserialize)] +struct LegacyEntry { + identity: (u64, u64), + mode: u32, +} + +#[cfg(target_os = "linux")] +fn decode_authority(raw: &[u8]) -> io::Result<Authority> { + #[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")] @@ -481,7 +615,16 @@ struct Authority { struct Entry { identity: (u64, u64), mode: u32, - digest: Option<String>, + // None for directories: removing children legitimately changes timestamps. + file_change: Option<FileChange>, +} + +#[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")] @@ -555,7 +698,7 @@ fn bounded_authority_bytes(authority: &Authority, limit: usize) -> io::Result<Ve if raw.len() > 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) @@ -577,6 +720,20 @@ mod linux { } fn open(parent: &fs::File, name: &OsStr, flags: i32) -> io::Result<fs::File> { + 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<fs::File> { 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). @@ -641,21 +798,12 @@ mod linux { pub(super) fn load_authority(parent: &fs::File, id: &str) -> io::Result<Option<Authority>> { let read = || -> io::Result<Authority> { 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)), @@ -664,12 +812,31 @@ mod linux { } } + pub(super) fn read_runtime_metadata( + dir: &fs::File, + name: &OsStr, + limit: usize, + ) -> io::Result<Vec<u8>> { + 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(); @@ -720,55 +887,13 @@ mod linux { } pub(super) fn inspect(dir: &fs::File, name: &OsStr) -> io::Result<Entry> { + #[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 @@ -779,7 +904,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(), + }), }) } @@ -798,7 +927,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(()) @@ -827,6 +960,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. @@ -918,6 +1053,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<usize>, + } + pub(super) enum Event { + EntryReadableOpen, + RuntimeMetadataRead(usize), + GitInspection, + EntryInspection, + } + thread_local! { + static ACTIVE: RefCell<Option<Audit>> = 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<T>(fail_after: Option<usize>, 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}; @@ -943,13 +1146,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!( @@ -991,7 +1604,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()); @@ -1024,7 +1637,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", @@ -1071,7 +1684,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 @@ -1082,7 +1695,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 @@ -1094,7 +1707,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(); @@ -1154,7 +1767,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(); @@ -1207,11 +1820,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/docs/development/workdir-removal.md b/docs/development/workdir-removal.md index d0f6f6a9f..716cea843 100644 --- a/docs/development/workdir-removal.md +++ b/docs/development/workdir-removal.md @@ -46,25 +46,96 @@ retry can reconcile the provider's current state. A retained partial-removal Workdir is listed as `cleanup_pending` with cleanliness `unknown`. List/detail observation checks its saved identity but does not read or -hash surviving checkout content. The saved removal witness is not a current -cleanliness assertion; only an actual removal retry runs the full content checks. +hash surviving checkout content. Saved removal authority is not a current +cleanliness assertion; an actual removal retry rechecks current safety conditions +and the metadata of surviving entries. ## Runtime safety boundary -The Runtime keeps removal content/identity evidence outside the tree being -removed. This is a factual witness for partial deletion, not a new recovery -state or cleanup stage. Surviving files are rechecked against it; an intact, -currently clean checkout can renew obsolete evidence without discarding newly -ignored/untracked content. Do not edit this witness manually. +The Runtime keeps removal identity and metadata evidence outside the tree being +removed, in `.cleanup-authority`. This is factual authority for partial deletion, +not a new recovery state or cleanup stage. Do not edit it manually. + +### Metadata-only removal authority + +Initial cleanup, ordinary retry, partial-removal recovery and each pre-unlink +check use descriptor-relative entry metadata, without following symlinks or +crossing mount boundaries. Cleanup does not read regular-file bodies or compute +content hashes, and does not create, refresh or compare durable content digests. +The inventory records: + +- entry identity (device/inode) and filesystem type; +- executable bits for regular files, plus size and modification time with + nanosecond precision for regular files and symlinks; +- identity and type only for directories. Directory size, permissions and + timestamps are not a change witness: cleanup's own child unlinks change + directory timestamps. + +This is metadata change protection, not a byte-equality guarantee. Deliberate +same-size writes that restore the recorded timestamp are not promised to be +detected. Symlink targets are not traversed or read for a content digest. +Necessary entry enumeration, metadata inspection and unlink remain; their work +is not proportional to the total regular-file body size. Normal Git inspection +may still read repository content, and the Runtime still reads its small managed +metadata records. Neither is a cleanup-specific full-content scan. + +### Clean checkout and ordinary retry + +Initial admission and metadata renewal from a usable checkout still require the +normal Git dirty check, including non-ignored untracked files. Dirty content is +not authorized for removal. Ignored entries already present at the initial clean +attempt are included in that attempt's metadata authority; Git ignoring a path +alone is not permission to discard later changes to it. + +On retry with an intact, usable Git checkout, new or metadata-changed ignored +entries and entries outside the tracked checkout/Git set are protected with +`changes_present`. A currently clean checkout may renew tracked-file and Git-entry +metadata only after the same Workdir root, checkout and Git identities and +materialization ownership are confirmed. Clean Git does not renew authority for +changed ignored/outside entries. A replaced Workdir root, checkout or Git directory, +or substituted ownership, is rejected with `ownership_unknown`. + +With the new metadata format, surviving entries that match a subset of the +persisted metadata authority can be removed after partial deletion even when Git +is no longer usable or present. Any surviving Runtime materialization record must +still be validated as the same materialization. Missing entries do not invalidate +that authority, and directory timestamp changes from earlier unlinks do not block +retry. If new entries or metadata changes break the subset match and usable Git +is unavailable, the partial cleanup returns `changes_unknown`, not +`changes_present`: it cannot establish current change safety or renew the +metadata authority. Unknown partial residue without metadata authority likewise +returns `changes_unknown`; a prior failed attempt is not proof of cleanliness. +Validated metadata-only residue (the Runtime materialization record and, at most, +an empty checkout directory) may finish without Git. + +### Existing digest-based authority + +Old `.cleanup-authority` entries may contain `digest` fields. Those digests are +ignored/discarded, not recomputed or compared, and do not authorize residual file +content under the new contract. Migration can establish new metadata authority +only with a usable current clean Git checkout, the same root, checkout and Git +identities, and validated materialization ownership. Even then, surviving ignored +entries or other entries outside the tracked checkout/Git set block migration +with `changes_unknown`: legacy entries have no size/mtime metadata against which +to verify these non-tracked survivors. Clean Git alone cannot authorize them. + +An incomplete legacy cleanup cannot use the new-format subset rule. Residual +files without usable clean Git return `changes_unknown`; they are not adopted, +and digest regeneration is not required. Metadata-only residue may finish only +with validated saved ownership, confirmed remaining identities, and validation +of any surviving Runtime materialization record. An empty same-identity root can +finish even when its record was already removed. Replaced root/checkout/Git identities or substituted ownership remain +`ownership_unknown`. Ordinary retry and Runtime restart use these same rules; +manual metadata edits and a new recovery lifecycle are not required. Mount-safe removal currently requires Linux `openat2` and `/proc` inspection. Unavailable inspection fails closed with `mount_check_unavailable`; there is no fallback to recursive deletion across an unchecked filesystem boundary. Runtime HTTP list, detail and removal requests run synchronous provider work on blocking workers rather than the HTTP executor. Removal reserves only the target -Workdir under the Runtime state mutex, then releases that mutex before content -inspection and unlink. Unrelated Worker list/detail, session observation and -subscription snapshots remain available while removal runs. Conflicting use, +Workdir under the Runtime state mutex, then releases that mutex before metadata +and Git inspection and unlink. Unrelated Worker list/detail, session observation +and subscription snapshots remain available while removal runs. Conflicting use, attachment, creation or removal of the same Workdir is rejected promptly rather than waiting under the global mutex. From 9e1de93a043f657ae6de2ef70259bfaa6f45d200 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 02:30:16 +0900 Subject: [PATCH 06/39] fix: bound Codex OAuth refresh bodies and deadline --- crates/agen/Cargo.toml | 1 + crates/agen/src/providers/codex/auth_json.rs | 25 +- crates/agen/src/providers/codex/mod.rs | 24 +- crates/agen/src/providers/codex/refresh.rs | 152 +++--- .../agen/src/providers/codex/refresh_tests.rs | 431 ++++++++++++++++++ 5 files changed, 571 insertions(+), 62 deletions(-) create mode 100644 crates/agen/src/providers/codex/refresh_tests.rs diff --git a/crates/agen/Cargo.toml b/crates/agen/Cargo.toml index e01b2ca40..0d667331c 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/providers/codex/auth_json.rs b/crates/agen/src/providers/codex/auth_json.rs index 374224730..212c75d34 100644 --- a/crates/agen/src/providers/codex/auth_json.rs +++ b/crates/agen/src/providers/codex/auth_json.rs @@ -108,13 +108,21 @@ pub async fn load(path: &Path) -> Result<AuthSnapshot, CodexAuthError> { /// /// 並行する Codex CLI / 別プロセスが先に refresh していた場合の /// fields を保護するため、書込前に再 load して merge する。 +/// Account / refresh token must still match the request's input: unrelated +/// fields can be merged, but another login or token rotation must not be overwritten. pub async fn persist_refreshed( path: &Path, + refresh_input: &AuthSnapshot, new_id_token: Option<String>, new_access_token: Option<String>, new_refresh_token: Option<String>, ) -> Result<AuthSnapshot, CodexAuthError> { 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"); // 未知フィールド agent_identity が保たれる @@ -266,7 +280,8 @@ mod tests { ); // 既存ファイルを 644 に変えてから persist → 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 a3a5ad2f0..4123f4925 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<Arc<tokio::sync::Barrier>>, state: Arc<Mutex<State>>, } @@ -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())), } } @@ -108,6 +116,11 @@ impl CodexAuthProvider { // 3. Refresh 直前に再 load し account_id 一致を確認(guarded reload)。 // 一致しなければ他プロセスが先に更新済 → 自分は refresh しない + #[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. 書き戻し(書込前に再 load + merge は persist_refreshed 内で実施) 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 1b526eeb9..a9844d66a 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<std::sync::Arc<tokio::sync::Notify>>, +} + +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<RefreshResponse, CodexAuthError> { + 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<RefreshResponse, CodexAuthError> { 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::<RefreshResponse>() - .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<Output = Result<reqwest::Response, reqwest::Error>>, - timeout: Duration, -) -> Result<reqwest::Response, CodexAuthError> { - 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<String> { mod tests { use super::*; - #[tokio::test] - async fn refresh_response_timeout_is_transient() { - let err = match response_with_timeout( - std::future::pending::<Result<reqwest::Response, reqwest::Error>>(), - 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<u8>, + chunked: bool, + gate: Option<Arc<Notify>>, + truncate: bool, +} + +impl Reply { + fn body(status: u16, body: impl Into<Vec<u8>>) -> Self { + Self { + status, + body: body.into(), + chunked: false, + gate: None, + truncate: false, + } + } + + fn gated(status: u16, body: impl Into<Vec<u8>>, gate: &Arc<Notify>) -> Self { + Self { + gate: Some(gate.clone()), + ..Self::body(status, body) + } + } +} + +struct Stub { + endpoint: String, + requests: mpsc::UnboundedReceiver<Value>, + 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<Reply>) -> 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<u8> { + 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<Notify>) -> Arc<CodexAuthProvider> { + 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<F: std::future::Future>(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()); + } +} From 520a898bd80e734f8e183a57d4e2b60b4f0789ad Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 02:35:42 +0900 Subject: [PATCH 07/39] fix: bound MCP stdio requests and shutdown across stdin waits --- crates/mcp/src/stdio.rs | 130 ++++++-- crates/mcp/src/stdio/deadline_tests.rs | 362 +++++++++++++++++++++++ crates/mcp/tests/fixtures/mock_server.rs | 19 +- crates/mcp/tests/stdio_lifecycle.rs | 45 +++ crates/worker/src/feature/mcp.rs | 76 ++++- 5 files changed, 602 insertions(+), 30 deletions(-) create mode 100644 crates/mcp/src/stdio/deadline_tests.rs diff --git a/crates/mcp/src/stdio.rs b/crates/mcp/src/stdio.rs index 7379d65e8..7465a39ef 100644 --- a/crates/mcp/src/stdio.rs +++ b/crates/mcp/src/stdio.rs @@ -11,11 +11,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"; @@ -31,8 +31,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, } @@ -570,6 +573,30 @@ pub fn resolve_stdio_server( Ok(spec) } +// The mutex orders entire frames, including replies emitted by the stdout reader. +type SharedStdin = Arc<Mutex<Option<Box<dyn AsyncWrite + Send + Unpin>>>>; + +/// 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, @@ -577,7 +604,7 @@ pub struct McpStdioClient { redactor: Redactor, diagnostics: Arc<Mutex<BoundedDiagnostics>>, list_changes: Arc<Mutex<BoundedListChanged>>, - stdin: Arc<Mutex<Option<ChildStdin>>>, + stdin: SharedStdin, child: Option<Child>, responses: mpsc::Receiver<ReaderEvent>, reader_task: JoinHandle<()>, @@ -663,7 +690,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, @@ -747,6 +774,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, @@ -902,23 +935,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)); } }; @@ -983,6 +1028,7 @@ impl McpStdioClient { method: &str, params: T, ) -> Result<(), McpClientError> { + let mut stage = McpRequestStage::WaitingForStdin; self.write_protocol( phase, &ClientNotification { @@ -990,6 +1036,7 @@ impl McpStdioClient { method, params, }, + &mut stage, ) .await } @@ -998,6 +1045,7 @@ impl McpStdioClient { &mut self, phase: McpPhase, value: &T, + stage: &mut McpRequestStage, ) -> Result<(), McpClientError> { write_json_line( &self.server_name, @@ -1006,6 +1054,7 @@ impl McpStdioClient { value, self.limits.max_protocol_bytes, &self.redactor, + stage, ) .await } @@ -1013,15 +1062,23 @@ impl McpStdioClient { /// Close stdin and wait for process exit, falling back to terminate and kill. pub async fn shutdown(&mut self) -> Result<ShutdownReport, McpClientError> { 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(); @@ -1033,7 +1090,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(); @@ -1195,12 +1252,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<McpDiagnostics>, + request_stage: Option<McpRequestStage>, } impl McpClientError { @@ -1210,6 +1268,7 @@ impl McpClientError { phase, kind, diagnostics: None, + request_stage: None, } } @@ -1223,6 +1282,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<McpRequestStage> { + self.request_stage + } + pub fn diagnostics(&self) -> Option<&McpDiagnostics> { self.diagnostics.as_ref() } @@ -1311,7 +1379,7 @@ struct ErrorObject<'a> { fn spawn_stdout_reader( server_name: String, stdout: ChildStdout, - stdin: Arc<Mutex<Option<ChildStdin>>>, + stdin: SharedStdin, tx: mpsc::Sender<ReaderEvent>, limits: McpStdioLimits, redactor: Redactor, @@ -1368,7 +1436,7 @@ fn spawn_stdout_reader( async fn handle_incoming_message( server_name: &str, - stdin: &Arc<Mutex<Option<ChildStdin>>>, + stdin: &SharedStdin, tx: &mpsc::Sender<ReaderEvent>, limits: &McpStdioLimits, redactor: &Redactor, @@ -1392,6 +1460,7 @@ async fn handle_incoming_message( &response, limits.max_protocol_bytes, redactor, + &mut McpRequestStage::WaitingForStdin, ) .await; } @@ -1484,10 +1553,11 @@ fn spawn_stderr_reader( async fn write_json_line<T: Serialize>( server_name: &str, phase: McpPhase, - stdin: &Arc<Mutex<Option<ChildStdin>>>, + 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( @@ -1507,27 +1577,34 @@ async fn write_json_line<T: Serialize>( } 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<R: AsyncRead + Unpin>( @@ -1684,3 +1761,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<StdMutex<Vec<u8>>>, + reached: Option<mpsc::UnboundedSender<()>>, + dropped: Arc<AtomicBool>, +} + +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<std::io::Result<usize>> { + 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<std::io::Result<()>> { + self.signal(); + if matches!(self.stall, Stall::Flush) { + Poll::Pending + } else { + Poll::Ready(Ok(())) + } + } + + fn poll_shutdown(self: Pin<&mut Self>, _: &mut Context<'_>) -> Poll<std::io::Result<()>> { + 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<ReaderEvent>) { + 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<F: Future>(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::<Value>(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::<Value>( + 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::<Value>(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::<Value>(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::<Value>(McpPhase::Running, "next-method", json!({})) + .await + .unwrap(); + assert_eq!(result, json!({"next": true})); + let captured = bytes.lock().unwrap().clone(); + let frames: Vec<Value> = 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<W> { + writer: W, + accepted: usize, + reached: Option<mpsc::UnboundedSender<()>>, +} +impl<W: AsyncWrite + Unpin> AsyncWrite for PendingObserver<W> { + fn poll_write( + mut self: Pin<&mut Self>, + cx: &mut Context<'_>, + buf: &[u8], + ) -> Poll<std::io::Result<usize>> { + 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<std::io::Result<()>> { + Pin::new(&mut self.writer).poll_flush(cx) + } + fn poll_shutdown(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<std::io::Result<()>> { + 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::<serde_json::Value>(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/worker/src/feature/mcp.rs b/crates/worker/src/feature/mcp.rs index ca13ae137..3af0c783b 100644 --- a/crates/worker/src/feature/mcp.rs +++ b/crates/worker/src/feature/mcp.rs @@ -1081,12 +1081,22 @@ impl Tool for McpStdioTool { &self, input_json: &str, _ctx: ToolExecutionContext, + ) -> Result<ToolOutput, ToolError> { + self.execute_with_limits(input_json, McpStdioLimits::default()) + .await + } +} + +impl McpStdioTool { + async fn execute_with_limits( + &self, + input_json: &str, + limits: McpStdioLimits, ) -> Result<ToolOutput, ToolError> { 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)) @@ -2208,6 +2218,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<Value> = 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 { From 74d0af93e3b80b795a66d0647226810f8fdc4055 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 02:38:38 +0900 Subject: [PATCH 08/39] fix: interrupt pending tool policies before provider admission --- crates/agen/src/engine.rs | 162 ++++++++++++++++++++++---------------- 1 file changed, 94 insertions(+), 68 deletions(-) diff --git a/crates/agen/src/engine.rs b/crates/agen/src/engine.rs index 16f76d790..beef6816a 100644 --- a/crates/agen/src/engine.rs +++ b/crates/agen/src/engine.rs @@ -71,6 +71,40 @@ 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<T>( + policy: impl Future<Output = Result<T, InterceptorError>>, + phase: InterceptorPhase, + cancel_rx: &mut mpsc::Receiver<()>, + pause_rx: &mut mpsc::Receiver<()>, +) -> Result<T, EngineError> { + 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()) +} + /// Tool registration error #[derive(Debug, thiserror::Error)] pub enum ToolRegistryError { @@ -748,14 +782,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { } } - 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 @@ -1531,43 +1557,24 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { 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( @@ -1945,8 +1952,10 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { // 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) @@ -1985,16 +1994,18 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { 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, @@ -2061,6 +2072,14 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { } } + // 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. @@ -2949,22 +2968,29 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { )), 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) From 2b1f68a3ead74876314737326b8d2deae1548a46 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 02:38:38 +0900 Subject: [PATCH 09/39] test: fence policy interruptions across tool ownership and restore --- crates/agen/tests/parallel_execution_test.rs | 573 +++++++++++++++++++ 1 file changed, 573 insertions(+) diff --git a/crates/agen/tests/parallel_execution_test.rs b/crates/agen/tests/parallel_execution_test.rs index de3156607..e31493751 100644 --- a/crates/agen/tests/parallel_execution_test.rs +++ b/crates/agen/tests/parallel_execution_test.rs @@ -2873,3 +2873,576 @@ 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<ControlledStreamClient>) { + engine.set_max_turns(Some(1)); + if matches!(self, Self::AfterResponse) { + engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse); + } + } +} + +struct NotifyOnDrop(Arc<tokio::sync::Notify>); + +impl Drop for NotifyOnDrop { + fn drop(&mut self) { + self.0.notify_one(); + } +} + +#[derive(Clone)] +struct PolicyBarrier { + phase: InterceptorPhase, + early_compatible: bool, + entered: Arc<tokio::sync::Notify>, + release: Arc<tokio::sync::Notify>, + invocations: Arc<AtomicUsize>, + continues: Arc<AtomicUsize>, + retired: Arc<tokio::sync::Notify>, + // Enqueue from the policy's final poll, after select has polled signals. + // This deterministically exercises the policy-completion/ownership race. + signal_on_continue: Option<tokio::sync::mpsc::Sender<()>>, +} + +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<PreToolAction> { + if self.phase == InterceptorPhase::PreToolCall { + self.wait().await; + } + Ok(PreToolAction::Continue) + } + + async fn on_assistant_turn_end( + &self, + _context: AssistantTurnEndContext<'_>, + ) -> InterceptorResult<TurnEndAction> { + if self.phase == InterceptorPhase::AssistantTurnEnd { + self.wait().await; + } + Ok(TurnEndAction::Finish) + } +} + +fn send_policy_response(tx: &tokio::sync::mpsc::UnboundedSender<Result<Event, ClientError>>) { + 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<AtomicUsize>, + started: Arc<tokio::sync::Notify>, + cancel_requested: Arc<tokio::sync::Notify>, + terminal: Arc<tokio::sync::Mutex<Option<tokio::sync::oneshot::Receiver<ToolOutput>>>>, + retired: Arc<tokio::sync::Notify>, +} + +#[async_trait] +impl Tool for PolicyOwnedTool { + async fn execute(&self, _: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> { + 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<dyn Tool>, + ) + }) + }; + 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::<Vec<_>>(); + 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::<Vec<_>>(); + 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::<Vec<_>>(), + 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); + } +} From e19742d509e7e64dd623319594df2ae5f7a7f93c Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 02:54:12 +0900 Subject: [PATCH 10/39] fix: preserve pause reclamation deadline during post-tool policy --- crates/agen/src/engine.rs | 124 ++++++++------ crates/agen/tests/parallel_execution_test.rs | 161 +++++++++++++++++++ 2 files changed, 232 insertions(+), 53 deletions(-) diff --git a/crates/agen/src/engine.rs b/crates/agen/src/engine.rs index beef6816a..d741d0015 100644 --- a/crates/agen/src/engine.rs +++ b/crates/agen/src/engine.rs @@ -105,6 +105,29 @@ async fn await_tool_policy<T>( 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<TokioInstant>) -> Self { + deadline.map_or(Self::Interruptible, Self::UntilPauseSafeBoundary) + } + + fn pause_deadline(self) -> Option<TokioInstant> { + match self { + Self::UntilPauseSafeBoundary(deadline) => Some(deadline), + _ => None, + } + } +} + /// Tool registration error #[derive(Debug, thiserror::Error)] pub enum ToolRegistryError { @@ -1723,7 +1746,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { result, None, batch.turn_id, - true, + PostToolPolicyWait::Interruptible, &batch.call_info_map, &mut batch.attempt_fence, &mut batch.terminal_call_ids, @@ -1750,7 +1773,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { result, None, batch.turn_id, - false, + PostToolPolicyWait::Skip, &batch.call_info_map, &mut batch.attempt_fence, &mut batch.terminal_call_ids, @@ -1798,7 +1821,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { result, Some(&attempt_id), batch.turn_id, - false, + PostToolPolicyWait::Skip, &batch.call_info_map, &mut batch.attempt_fence, &mut batch.terminal_call_ids, @@ -1821,7 +1844,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { 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, @@ -1844,28 +1867,6 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { force_cancel: bool, initial_pause_requested: bool, ) -> Result<ToolExecutionResult, EngineError> { - 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) @@ -1879,6 +1880,28 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { 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; @@ -1890,7 +1913,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { 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, @@ -2156,7 +2179,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { result, None, turn_id, - true, + PostToolPolicyWait::Interruptible, &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -2188,7 +2211,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { result, Some(&attempt_id), turn_id, - true, + PostToolPolicyWait::for_pause_deadline(pause_deadline), &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -2269,7 +2292,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { result, Some(&attempt_id), turn_id, - false, + PostToolPolicyWait::Skip, &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -2297,7 +2320,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { ToolResult::outcome_unknown(call_id), Some(attempt_id), turn_id, - false, + PostToolPolicyWait::Skip, &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -2347,7 +2370,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { 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, ( @@ -2432,7 +2455,9 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { ); 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), @@ -2448,27 +2473,20 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { 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)); @@ -2506,7 +2524,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { ToolResult::outcome_unknown(&call_id), None, self.turn_count, - false, + PostToolPolicyWait::Skip, &call_info_map, &mut attempt_fence, &mut terminal_call_ids, @@ -3556,7 +3574,7 @@ impl<C: LlmClient, S: EngineState, A: Send + Sync> Engine<C, S, A> { 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/tests/parallel_execution_test.rs b/crates/agen/tests/parallel_execution_test.rs index e31493751..ae3d63e78 100644 --- a/crates/agen/tests/parallel_execution_test.rs +++ b/crates/agen/tests/parallel_execution_test.rs @@ -3446,3 +3446,164 @@ async fn turn_end_final_poll_signal_wins_over_finish_without_tools() { 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<TurnEndAction> { + self.turn.wait().await; + Ok(TurnEndAction::Finish) + } + async fn post_tool_call( + &self, + _: &ToolResultInfo<'_>, + ) -> InterceptorResult<PostToolAction> { + 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<dyn Tool>, + ) + }) + }; + 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 + ); +} From 2a55bef036546d810bf0b7ac10c6c24a294dce8d Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 02:54:45 +0900 Subject: [PATCH 11/39] feat(ticket): persist Orchestrator assignment tracking and audit --- crates/server-api/src/lib.rs | 125 ++ .../src/repository_openapi_typescript.rs | 21 + crates/ticket/src/assignment_schema.sql | 105 ++ crates/ticket/src/assignment_tracking.rs | 900 ++++++++++ .../ticket/src/assignment_tracking_schema.sql | 128 ++ .../ticket/src/assignment_tracking_tests.rs | 730 ++++++++ crates/ticket/src/lib.rs | 2 + crates/ticket/src/sqlite_schema.rs | 92 +- .../feature/builtin/resource_projection.rs | 74 +- crates/worker/src/feature/builtin/ticket.rs | 96 +- .../builtin/ticket/assignment_tracking.rs | 466 ++++++ .../src/feature/builtin/ticket/tags/tests.rs | 4 +- .../src/assignment_tracking.rs | 173 ++ .../src/assignment_tracking_tests.rs | 1486 +++++++++++++++++ crates/workspace-server/src/authority.rs | 19 + crates/workspace-server/src/latest_schema.sql | 114 +- crates/workspace-server/src/server.rs | 387 ++++- .../src/server/tests/drive_api_tests.rs | 2 + .../src/server/tests/subjektiv_jobs_tests.rs | 1 + .../server/tests/worker_operations_tests.rs | 4 + .../src/server_assignment_tracking_tests.rs | 1344 +++++++++++++++ crates/workspace-server/src/store.rs | 826 ++++++--- .../src/ticket_tag_upgrade_tests.rs | 20 +- .../src/worker_source/runtime_request.rs | 4 + .../src/workspace_deletion.rs | 89 +- .../development/ticket-assignment-tracking.md | 47 + ...26-10-11-subworker-advisory-surface-gap.md | 15 + openapi/server-api.json | 710 +++++++- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 26 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../lib/workspace/api/ticket-browser.test.ts | 47 + .../src/lib/workspace/api/ticket-browser.ts | 133 ++ 35 files changed, 7706 insertions(+), 492 deletions(-) create mode 100644 crates/ticket/src/assignment_schema.sql create mode 100644 crates/ticket/src/assignment_tracking.rs create mode 100644 crates/ticket/src/assignment_tracking_schema.sql create mode 100644 crates/ticket/src/assignment_tracking_tests.rs create mode 100644 crates/worker/src/feature/builtin/ticket/assignment_tracking.rs create mode 100644 crates/workspace-server/src/assignment_tracking.rs create mode 100644 crates/workspace-server/src/assignment_tracking_tests.rs create mode 100644 crates/workspace-server/src/server_assignment_tracking_tests.rs create mode 100644 docs/development/ticket-assignment-tracking.md create mode 100644 docs/report/2026-10-11-subworker-advisory-surface-gap.md diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index b34b84458..3acc5956a 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -347,6 +347,12 @@ impl_openapi_schema!( TicketDependencyCheckResponse, TicketDetail, TicketCaseUpdateRequest, + TicketAssignmentTrackingUpdateRequest, + TicketAssignmentTrackingRecord, + TicketAssignmentTrackingList, + TicketAssignmentTrackingQuery, + TicketAssignmentTrackingHistoryQuery, + TicketAssignmentTrackingHistoryPage, TicketSummary, TicketDoctorResponse, TicketIntakeSummaryRequest, @@ -3190,6 +3196,30 @@ pub trait ServerApi { #[path] ticket_id: String, #[body] request: TicketCaseUpdateRequest, ) -> Result<TicketSummary, RepositoryApiError>; + #[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<TicketAssignmentTrackingList, RepositoryApiError>; + #[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<TicketAssignmentTrackingRecord, RepositoryApiError>; + #[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<TicketAssignmentTrackingHistoryPage, RepositoryApiError>; #[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, @@ -3254,6 +3284,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, @@ -3263,6 +3294,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, @@ -3571,6 +3603,9 @@ pub struct ServerWorkerSource { pub struct ServerRuntimeSource { pub runtime_id: String, pub worker_id: Option<String>, + /// 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, } #[derive(Clone)] @@ -7631,6 +7666,11 @@ 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<TicketAssignmentTrackingProjection>") + )] + pub assignment_tracking: Vec<ticket::TicketAssignmentTrackingProjection>, pub evidence: TicketEvidenceSummary, pub merge_requests: Vec<TicketMergeRequestSummary>, pub merge_request: Option<TicketMergeRequestSummary>, @@ -7700,6 +7740,11 @@ pub struct TicketDetail { pub linked_objectives: Vec<ObjectiveLinkSummary>, pub implementation_reports: Vec<TicketEvidenceEvent>, pub assignments: Vec<TicketRoleAssignmentSummary>, + #[cfg_attr( + feature = "typescript", + ts(type = "Array<TicketAssignmentTrackingProjection>") + )] + pub assignment_tracking: Vec<ticket::TicketAssignmentTrackingProjection>, pub current_worker: Option<TicketAssignmentSummary>, pub assignment_diagnostics: Vec<String>, pub action_eligibility: TicketActionEligibility, @@ -7777,6 +7822,42 @@ pub struct TicketCaseUpdateRequest { #[serde(default)] pub references: Vec<ticket::TicketReference>, } +transparent_ticket_dto!( + TicketAssignmentTrackingUpdateRequest, + ticket::TicketAssignmentTrackingUpdate +); +transparent_ticket_dto!( + TicketAssignmentTrackingRecord, + ticket::TicketAssignmentTrackingProjection +); +transparent_ticket_dto!( + TicketAssignmentTrackingList, + Vec<ticket::TicketAssignmentTrackingProjection> +); + +#[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<String>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingHistoryQuery { + pub assignment_id: String, + pub after_event_id: Option<String>, + #[schemars(range(min = 1, max = 100))] + pub limit: Option<usize>, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentTrackingHistoryPage { + pub items: Vec<ticket::TicketAssignmentTrackingHistoryEntry>, + pub next_event_id: Option<String>, +} + transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest); transparent_ticket_dto!( WorkspaceTicketTagMutationRequest, @@ -13022,6 +13103,50 @@ mod tests { } } + #[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::<TicketAssignmentTrackingUpdateRequest>(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::<TicketAssignmentTrackingUpdateRequest>(invalid) + .is_err(), + "{field}" + ); + } + let mut invalid = input.clone(); + invalid["tracking"]["status"] = serde_json::json!("running"); + assert!( + serde_json::from_value::<TicketAssignmentTrackingUpdateRequest>(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!({ diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index 1e96670c3..fe533c862 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -500,6 +500,27 @@ fn validate_ticket_browser_operations( request: Some("TicketCaseUpdateRequest"), successes: &[("200", "TicketSummary")], }, + 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", method: "post", 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..9aada331a --- /dev/null +++ b/crates/ticket/src/assignment_tracking.rs @@ -0,0 +1,900 @@ +//! Ticket-owned assignment tracking. Runtime execution, grants and notification +//! delivery are intentionally not tracking status. The host resolves the current +//! workspace-agent Worker and authenticates it before constructing source context. +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<String>, + pub progress: Option<String>, + pub next_action: Option<String>, + pub result: Option<String>, +} + +#[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<TicketAssignmentRelease>, + pub current: bool, + pub tracking: Option<TicketAssignmentTracking>, + /// 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<String>, + pub updated_at: Option<String>, + pub migration_diagnostic: Option<String>, +} + +/// 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<String>, + pub actor: String, + pub at: String, + pub reason: Option<String>, + pub replacement_assignment_id: Option<String>, + pub last_tracking: Option<TicketAssignmentTracking>, +} + +#[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<TicketAssignmentTrackingHistoryEntry>, + pub next_event_id: Option<String>, +} + +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<Option<TicketAssignmentTrackingProjection>> { + 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<String>>(0)?,r.get::<_,Option<String>>(1)?,r.get::<_,Option<String>>(2)?,r.get::<_,Option<String>>(3)?,r.get::<_,bool>(4)?, r.get::<_,String>(5)?, r.get::<_,String>(6)?,r.get::<_,Option<String>>(7)?,r.get::<_,Option<String>>(8)?,r.get::<_,Option<String>>(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<TicketAssignmentTracking> = 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<String>>(2)?,r.get::<_,String>(3)?,r.get::<_,String>(4)?,r.get::<_,Option<String>>(5)?,r.get::<_,Option<String>>(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<Vec<TicketAssignmentTrackingProjection>> { + 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::<std::result::Result<Vec<_>,_>>().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<Vec<TicketAssignmentTrackingHistoryEntry>> { + 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<TicketAssignmentTrackingHistoryPage> { + 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<i64>=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::<Result<Vec<_>>>()?; + 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<TicketAssignmentTrackingProjection> { + 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<String>>(3)?,r.get::<_,bool>(4)?))).map_err(sqlite_err)? + .collect::<std::result::Result<Vec<_>,_>>().map_err(sqlite_err)?; + for (workspace, ticket, assignment, snapshot, current) in rows { + let original = snapshot + .as_deref() + .map(serde_json::from_str::<TicketLegacyWorkflowSnapshot>) + .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<String>>(1)?,r.get::<_,Option<bool>>(2)?.unwrap_or(false),r.get::<_,Option<String>>(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))",params![workspace,ticket,index],|r|r.get(0)).map_err(sqlite_err)?; + let superseded:bool=conn.query_row("SELECT EXISTS(SELECT 1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2 AND 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<String> = 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::<std::result::Result<Vec<_>, _>>() + .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<Vec<(String, String, i64, Option<String>, 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::<std::result::Result<Vec<_>, _>>() + .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<BTreeSet<(String, String, String, String)>> { + 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::<std::result::Result<BTreeSet<_>, _>>() + .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<BTreeSet<(bool, bool, Vec<String>)>> { + 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::<std::result::Result<Vec<_>, _>>() + .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::<std::result::Result<Vec<_>, _>>() + .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; 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..ca66d3636 --- /dev/null +++ b/crates/ticket/src/assignment_tracking_tests.rs @@ -0,0 +1,730 @@ +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<String> = 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<TicketAssignmentTrackingProjection> { + 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::<TicketAssignmentTrackingUpdate>(value).is_err()); + let mut value = serde_json::to_value(&input).unwrap(); + value["tracking"]["status"] = serde_json::json!("running"); + assert!(serde_json::from_value::<TicketAssignmentTrackingUpdate>(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") + ); +} diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 3b8a29e4f..d65d78b1a 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -20,9 +20,11 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use thiserror::Error; +mod assignment_tracking; pub mod config; mod sqlite_schema; mod tags; +pub use assignment_tracking::*; pub use tags::{ TagListRequest, TicketTagSetRequest, WorkspaceTag, WorkspaceTagAction, WorkspaceTagMutation, }; diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 1a8c022cd..96e38241c 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,7 +7,7 @@ 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 = 10; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 11; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; #[derive(Clone, Copy)] @@ -307,10 +307,11 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { } } -/// Composes retained component 6/7, tags-only 8, or cases-only 9 into component 10. -/// The host owns the surrounding transaction (for example Workspace 90→91). +/// 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 10 is verified only; immutable judgments are never reapplied. +/// 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( @@ -334,22 +335,30 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R create_latest_ticket_schema(connection)?; connection .execute( - "INSERT INTO ticket_schema_migrations VALUES (10, ?1, ?2)", + "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 = match applied.iter().next() { - Some((&version, name)) - if applied.len() == 1 - && name == BASELINE_MIGRATION_NAME - && matches!(version, 6..=10) => - { - version - } - _ => { - validate_applied_migrations(&applied)?; - unreachable!() + 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 { @@ -370,9 +379,9 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R } TicketSchemaShape::Tags8 => migrate_legacy_cases(connection)?, TicketSchemaShape::Cases9 => add_tag_schema(connection)?, - TicketSchemaShape::Composed10 => {} + TicketSchemaShape::Composed10 | TicketSchemaShape::Tracking11 => {} } - if version != 10 { + if version < 10 { connection .execute( "UPDATE ticket_schema_migrations SET version = 10, applied_at = ?1", @@ -380,6 +389,10 @@ pub fn migrate_sqlite_ticket_schema_in_transaction(connection: &Connection) -> R ) .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) })(); @@ -402,6 +415,7 @@ enum TicketSchemaShape { Tags8, Cases9, Composed10, + Tracking11, } impl TicketSchemaShape { fn from_version(version: i64) -> Self { @@ -410,6 +424,7 @@ impl TicketSchemaShape { 8 => Self::Tags8, 9 => Self::Cases9, 10 => Self::Composed10, + 11 => Self::Tracking11, _ => unreachable!(), } } @@ -419,19 +434,20 @@ impl TicketSchemaShape { Self::Tags8 => 8, Self::Cases9 => 9, Self::Composed10 => 10, + Self::Tracking11 => 11, } } fn tags(self) -> bool { - matches!(self, Self::Tags8 | Self::Composed10) + matches!(self, Self::Tags8 | Self::Composed10 | Self::Tracking11) } fn cases(self) -> bool { - matches!(self, Self::Cases9 | Self::Composed10) + 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::Composed10) + verify_ticket_schema_shape(connection, TicketSchemaShape::Tracking11) } /// Retained Workspace schema-66 migration produces the pre-classification v7 @@ -469,7 +485,12 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) Ok(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!( @@ -769,6 +790,13 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) 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 { @@ -788,7 +816,8 @@ fn verify_ticket_schema_shape(connection: &Connection, shape: TicketSchemaShape) 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. @@ -1040,7 +1069,13 @@ fn validate_applied_migrations(applied: &BTreeMap<i64, String>) -> Result<()> { LATEST_SQLITE_TICKET_SCHEMA_VERSION, 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!( @@ -1541,7 +1576,10 @@ mod tests { assert!(!migrated_attributes.contains_key("ref_selector")); assert_eq!( load_applied_migrations(&connection).unwrap(), - BTreeMap::from([(10, BASELINE_MIGRATION_NAME.to_owned())]) + BTreeMap::from([ + (10, BASELINE_MIGRATION_NAME.to_owned()), + (11, "Ticket-owned assignment tracking".to_owned()) + ]) ); } @@ -1683,7 +1721,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 10 baseline marker" + "migration history must contain only the canonical version 11 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -1705,7 +1743,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 10 baseline marker" + "migration history must contain only the canonical version 11 baseline marker" )); assert!(!table_exists(&connection, "typed_tickets").unwrap()); } diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index ae18dccbd..9d149688e 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -17,6 +17,7 @@ struct ModelTicketQueryItem { closure_operation_key: Option<String>, current_closure: Option<ticket::TicketClosureJudgment>, legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>, + assignment_tracking: Vec<ModelAssignmentTracking>, tags: Vec<ticket::WorkspaceTag>, readiness: Option<String>, priority: Option<String>, @@ -46,6 +47,7 @@ pub(super) struct ModelTicketDetail { closure_operation_key: Option<String>, current_closure: Option<ticket::TicketClosureJudgment>, legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>, + assignment_tracking: Vec<ModelAssignmentTracking>, tags: Vec<ticket::WorkspaceTag>, readiness: Option<String>, priority: Option<String>, @@ -98,6 +100,21 @@ 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<ticket::TicketAssignmentRelease>, + current: bool, + tracking: Option<ticket::TicketAssignmentTracking>, + last_event_id: Option<String>, + updated_at: Option<String>, + migration_diagnostic: Option<String>, +} + #[derive(Debug, Serialize)] struct ModelWorkerSummary { worker: String, @@ -266,6 +283,7 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri closure_operation_key: typed_field(item, "closure_operation_key")?, current_closure: typed_field(item, "current_closure")?, legacy_handoff: typed_field(item, "legacy_handoff")?, + assignment_tracking: project_assignment_tracking(item)?, tags: project_tags(item)?, readiness: optional_string(item, "readiness")?, priority: optional_string(item, "priority")?, @@ -322,6 +340,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S current_closure: typed_field(root, "current_closure")?, legacy_handoff: typed_field(root, "legacy_handoff")?, closure_history: typed_field(root, "closure_history")?, + assignment_tracking: project_assignment_tracking(root)?, tags: project_tags(root)?, readiness: optional_string(root, "readiness")?, priority: optional_string(root, "priority")?, @@ -672,6 +691,36 @@ fn object_field<'a>( .ok_or_else(|| format!("missing or invalid {key}")) } +fn project_assignment_tracking( + object: &Map<String, Value>, +) -> Result<Vec<ModelAssignmentTracking>, 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<String, Value>) -> Result<Vec<ticket::WorkspaceTag>, String> { let values = array_field(object, "tags")?; if values.len() > 100 { @@ -770,7 +819,7 @@ mod tests { fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() { for state in ["open", "closed"] { let projected = project_ticket_detail(json!({ - "resource_key": "T-718", "tags": [], "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": [], @@ -812,7 +861,7 @@ mod tests { "queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null }); let response = json!({ - "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}], + "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, @@ -857,7 +906,7 @@ mod tests { "linked_tickets": ["00001M0E82D1V"], "linked_ticket_summaries": [{ "id": "00001M0E82D1V", - "resource_key": "T-496", "tags": [], + "resource_key": "T-496", "assignment_tracking": [], "tags": [], "title": "Ticket", "state": "closed", "updated_at": "2026-01-02T00:00:00Z" @@ -888,7 +937,7 @@ mod tests { "record_authority": "workspace_sqlite", "items": [{ "id": "00001TICKETINTERNAL", - "resource_key": "T-543", "tags": [], + "resource_key": "T-543", "assignment_tracking": [], "tags": [], "title": "Ticket", "state": "open", "closure_operation_key": null, "current_closure": null, "legacy_handoff": null, @@ -960,7 +1009,7 @@ mod tests { fn ticket_detail_projection_accepts_current_blocker_shape() { let projected = project_ticket_detail(json!({ "id": "internal-ticket", - "resource_key": "T-588", "tags": [], + "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", @@ -1064,7 +1113,7 @@ mod tests { "review_excerpt": "Reviewed source" }); let projected = project_ticket_detail(json!({ - "resource_key": "T-716", "tags": [], + "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", @@ -1246,13 +1295,14 @@ mod tests { assert!(error.contains("W-")); } #[test] - fn ticket_query_and_detail_preserve_classification_separately_from_closure_judgment() { + 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", "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure, + "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(); @@ -1288,6 +1338,14 @@ mod tests { "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. diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index 1a3ced7c6..a6e6e527a 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -4,6 +4,7 @@ //! module binds an authority-scoped Workspace client, declares the built-in //! feature, and contributes those tools through the normal registry path. +mod assignment_tracking; mod tags; use std::collections::{BTreeMap, BTreeSet, HashMap}; @@ -443,6 +444,9 @@ fn workspace_ticket_decision_definition( fn workspace_ticket_tool_description(name: &str) -> String { match name { "TicketCaseUpdate" => TICKET_CASE_UPDATE_DESCRIPTION.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), } @@ -576,6 +580,12 @@ impl TicketFeatureAccess { .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), + ) .collect() } @@ -592,7 +602,13 @@ impl TicketFeatureAccess { } } -const READ_ONLY_TOOL_NAMES: &[&str] = &["QueryTicket", "ShowTicket", "WorkspaceTagList"]; +const READ_ONLY_TOOL_NAMES: &[&str] = &[ + "QueryTicket", + "ShowTicket", + "WorkspaceTagList", + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", +]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", @@ -625,6 +641,8 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "WorkspaceTagRename", "WorkspaceTagDelete", "TicketTagsSet", + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", ]; #[cfg(test)] @@ -643,6 +661,9 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "WorkspaceTagRename", "WorkspaceTagDelete", "TicketTagsSet", + "TicketAssignmentTrackingQuery", + "TicketAssignmentTrackingHistory", + "TicketAssignmentTrackingUpdate", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ @@ -1515,6 +1536,24 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> { identity_field: Some("ticket"), mutating: true, }, + "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, + }, "TicketCaseUpdate" => NativeTicketOperation { operation: "case_update", surface: NativeTicketSurface::Item, @@ -1604,6 +1643,15 @@ pub(crate) fn enabled_ticket_definitions( 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)); + } + } definitions } @@ -2700,23 +2748,10 @@ mod tests { .collect::<Vec<_>>(); assert_eq!( authoring_operations, - [ - "create", - "edit", - "query", - "read", - "comment", - "mark_ready", - "queue", - "case_update", - "record_relation", - "remove_relation", - "tag_list", - "tag_create", - "tag_rename", - "tag_delete", - "tags_set", - ] + WORKSPACE_AUTHORING_TOOL_NAMES + .iter() + .map(|name| native_ticket_operation(name).unwrap().operation) + .collect::<Vec<_>>() ); let item = ticket_descriptor( &authoring @@ -2753,22 +2788,10 @@ mod tests { .iter() .map(|tool| tool.projection.operation) .collect::<Vec<_>>(), - [ - "query", - "read", - "comment", - "case_update", - "dependency_check", - "record_relation", - "remove_relation", - "record_orchestration_plan", - "query_orchestration_plans", - "tag_list", - "tag_create", - "tag_rename", - "tag_delete", - "tags_set", - ] + WORKFLOW_TOOL_NAMES + .iter() + .map(|name| native_ticket_operation(name).unwrap().operation) + .collect::<Vec<_>>() ); assert_eq!( native_ticket_tools(client, TicketFeatureAccess::review()) @@ -2776,7 +2799,10 @@ mod tests { .iter() .map(|tool| tool.projection.operation) .collect::<Vec<_>>(), - ["query", "read", "tag_list"] + READ_ONLY_TOOL_NAMES + .iter() + .map(|name| native_ticket_operation(name).unwrap().operation) + .collect::<Vec<_>>() ); } 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..349d1aacf --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs @@ -0,0 +1,466 @@ +//! Self-reported handling of one immutable delegation. This transport never +//! supplies actor/role authority or converts tracking into execution lifecycle. +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<String>, +} + +#[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<String>, + #[schemars(range(min = 1, max = 100))] + limit: Option<usize>, +} + +#[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. 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<dyn WorkspaceClient>, + kind: TrackingToolKind, +} + +#[async_trait] +impl Tool for TrackingTool { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> { + 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<dyn WorkspaceClient>, + 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<dyn Tool>, + ) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::worker::{WorkspaceClientError, WorkspaceResponse}; + + #[derive(Debug)] + struct Transport { + workspace: Option<String>, + response: WorkspaceResponse, + requests: Mutex<Vec<WorkspaceRequest>>, + } + 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<WorkspaceResponse, WorkspaceClientError> { + self.requests.lock().unwrap().push(request); + Ok(self.response.clone()) + } + } + fn transport(status: u16, body: &str) -> Arc<Transport> { + 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::<Value>(&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/tests.rs b/crates/worker/src/feature/builtin/ticket/tags/tests.rs index 792ca3790..b1044b0e0 100644 --- a/crates/worker/src/feature/builtin/ticket/tags/tests.rs +++ b/crates/worker/src/feature/builtin/ticket/tags/tests.rs @@ -110,7 +110,7 @@ async fn classification_tool_schemas_require_observations_and_reject_actor_claim #[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":[], "tags":[tag()], "matched_fields":[], "linked_objective_keys":[], "relation_count":0, "blocker_count":0, "unresolved_blocker_count":0, "unresolved_review_count":0, "merge_requests":[]}); + 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)(); @@ -139,7 +139,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations() 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":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]}); + 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)(); diff --git a/crates/workspace-server/src/assignment_tracking.rs b/crates/workspace-server/src/assignment_tracking.rs new file mode 100644 index 000000000..c798df247 --- /dev/null +++ b/crates/workspace-server/src/assignment_tracking.rs @@ -0,0 +1,173 @@ +//! Workspace adapter: Ticket owns the assignment/tracking schema and mutations. +//! Authenticated source is constructed by Backend after singleton resolution; +//! grants, work releases and startup recovery never derive from tracking status. +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<TicketAssignmentTrackingProjection> { + 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<Option<TicketAssignmentTrackingProjection>> { + 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<Vec<TicketAssignmentTrackingProjection>> { + 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<Vec<TicketAssignmentTrackingHistoryEntry>> { + 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<ticket::TicketAssignmentTrackingHistoryPage> { + 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()), + }) + }) +} 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..3fd7f8b6b --- /dev/null +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -0,0 +1,1486 @@ +// 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(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(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<_>>(), + vec![92] + ); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 92); + 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()) + ] + ); + 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(&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(&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)?, 92); + 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<String>, Option<String>) = 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(&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(&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(&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(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::<Vec<_>>(), + 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::<Vec<_>>(), + 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::<Vec<_>>(), + 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() + ); +} diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index dd4d30ebb..686be3115 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -238,6 +238,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<Vec<ticket::TicketAssignmentTrackingProjection>> { + 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<dyn TicketSourceCommitResolver>, @@ -905,7 +919,11 @@ 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, @@ -2330,6 +2348,7 @@ fn ticket_query_item( } } TicketQueryItem { + assignment_tracking: detail.assignment_tracking.clone(), id: summary.id, resource_key: summary.resource_key, title: summary.title, diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 859f23587..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 diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d391d9f33..81249d6d7 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2805,7 +2805,7 @@ async fn authorize_workspace_api_request( next: axum::middleware::Next, ) -> Response { if !request.uri().path().starts_with("/api/") - && ticket_case_workspace_id(request.uri().path()).is_none() + && canonical_ticket_operation_workspace_id(request.uri().path()).is_none() { return next.run(request).await; } @@ -3028,7 +3028,7 @@ async fn dispatch_workspace_request( if let Some(workspace_id) = workspace_id && (path.starts_with("/api/w/") || path.starts_with("/api/runtime/v1/workspaces/") - || ticket_case_workspace_id(&path).is_some()) + || canonical_ticket_operation_workspace_id(&path).is_some()) && let Err(response) = authorize_scoped_workspace_request(&api, workspace_id, &mut request).await { @@ -3102,9 +3102,9 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp static_file_or_spa_response(static_root, path).await } -// The canonical case route is deliberately not an /api/w alias. Recognize only -// this operation so its authentication/scope is identical without broadening CRUD. -fn ticket_case_workspace_id(path: &str) -> Option<&str> { +// Canonical case/tracking 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()?; @@ -3113,13 +3113,20 @@ fn ticket_case_workspace_id(path: &str) -> Option<&str> { (!workspace_id.is_empty() && resource == "tickets" && !ticket_id.is_empty() - && operation == "case" - && segments.next().is_none()) + && match operation { + "case" => segments.next().is_none(), + "assignment-tracking" => match segments.next() { + None => true, + Some("history") => segments.next().is_none(), + _ => false, + }, + _ => false, + }) .then_some(workspace_id) } fn scoped_workspace_id(path: &str) -> Option<&str> { - if let Some(workspace_id) = ticket_case_workspace_id(path) { + 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/") { @@ -6229,6 +6236,7 @@ async fn attach_server_request_context( .extensions() .get::<crate::worker_source::VerifiedRuntimeRequestSource>() .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(), }); @@ -6598,6 +6606,11 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::ticket_case_update( 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(), )) @@ -10535,6 +10548,75 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } + async fn ticket_assignment_tracking_get( + &self, + _context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentTrackingQuery, + ) -> std::result::Result<server_api::TicketAssignmentTrackingList, server_api::RepositoryApiError> + { + let api = self.workspace_api()?; + 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::<Vec<_>>()) + } else { + api.authority.ticket_assignment_tracking(&ticket_id) + } + .map_err(ApiError::from) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketAssignmentTrackingList(values)) + } + + 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, @@ -10554,6 +10636,7 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_assignment_set( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, role: String, @@ -10562,8 +10645,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), ) @@ -10574,6 +10659,7 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_assignment_clear( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, role: String, @@ -10582,8 +10668,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), ) @@ -13664,10 +13752,85 @@ async fn finalize_manual_ticket_worker_claim( } } +#[cfg(test)] async fn scoped_set_ticket_assignment( + state: State<WorkspaceApi>, + path: AxumPath<(String, String, String)>, + request: Json<server_api::SetTicketRoleAssignmentRequest>, +) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { + 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<WorkspaceApi>, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + path: AxumPath<(String, String, String)>, + request: Json<server_api::SetTicketRoleAssignmentRequest>, +) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { + validate_workspace_scope(&api, &path.0.0)?; + let (author, source) = authenticated_assignment_source(&api, &headers, context)?; + apply_set_ticket_assignment(State(api), path, request, author, Some(source)).await +} + +async fn apply_set_ticket_assignment( State(api): State<WorkspaceApi>, AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, Json(request): Json<server_api::SetTicketRoleAssignmentRequest>, + assigned_by: String, + source: Option<crate::store::TicketOrchestratorAssignmentSourceContext>, ) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { validate_workspace_scope(&api, &workspace_id)?; let ticket = api.authority.ticket(&id)?; @@ -13685,7 +13848,7 @@ async fn scoped_set_ticket_assignment( assignment_id: new_id("tasg"), role, principal, - assigned_by: "workspace-web".to_string(), + assigned_by, assigned_at, }; let assignment = match role { @@ -13705,13 +13868,25 @@ async fn scoped_set_ticket_assignment( ) .into()); } - api.store.set_current_ticket_role_assignment( - &record, - expected_assignment_id.as_deref(), - &new_id("tasev"), - &operation_id, - expected_assignment_id.is_some(), - )? + if let Some(source) = source.as_ref() { + api.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. + api.store.set_current_ticket_role_assignment( + &record, + expected_assignment_id.as_deref(), + &new_id("tasev"), + &operation_id, + expected_assignment_id.is_some(), + )? + } } TicketAssignmentRole::Worker => { if expected_assignment_id.is_some() { @@ -13744,10 +13919,33 @@ async fn scoped_set_ticket_assignment( })) } +#[cfg(test)] async fn scoped_clear_ticket_assignment( + state: State<WorkspaceApi>, + path: AxumPath<(String, String, String)>, + query: Query<server_api::ClearTicketRoleAssignmentQuery>, +) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { + apply_clear_ticket_assignment(state, path, query, "workspace-web".into(), None).await +} + +async fn scoped_clear_ticket_assignment_with_context( + State(api): State<WorkspaceApi>, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + path: AxumPath<(String, String, String)>, + query: Query<server_api::ClearTicketRoleAssignmentQuery>, +) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { + validate_workspace_scope(&api, &path.0.0)?; + let (author, source) = authenticated_assignment_source(&api, &headers, context)?; + apply_clear_ticket_assignment(State(api), path, query, author, Some(source)).await +} + +async fn apply_clear_ticket_assignment( State(api): State<WorkspaceApi>, AxumPath((workspace_id, id, role)): AxumPath<(String, String, String)>, Query(query): Query<server_api::ClearTicketRoleAssignmentQuery>, + author: String, + source: Option<crate::store::TicketOrchestratorAssignmentSourceContext>, ) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { validate_workspace_scope(&api, &workspace_id)?; let ticket = api.authority.ticket(&id)?; @@ -13766,17 +13964,36 @@ async fn scoped_clear_ticket_assignment( .ok_or_else(|| { Error::TicketAssignmentConflict("unassign requires assignment_id".to_string()) })?; - 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"), - )?; + 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 `{}`", @@ -16509,6 +16726,82 @@ async fn scoped_ticket_case_update( apply_ticket_case_update(State(api), source, None, path, request).await } +async fn scoped_ticket_assignment_tracking_update_with_context( + State(api): State<WorkspaceApi>, + headers: HeaderMap, + context: &server_api::ServerRequestContext, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Json(update): Json<ticket::TicketAssignmentTrackingUpdate>, +) -> ApiResult<Json<ticket::TicketAssignmentTrackingProjection>> { + 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, + 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)) +} + +async fn scoped_ticket_assignment_tracking_history( + State(api): State<WorkspaceApi>, + AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, + Query(query): Query<server_api::TicketAssignmentTrackingHistoryQuery>, +) -> ApiResult<Json<server_api::TicketAssignmentTrackingHistoryPage>> { + 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, + })) +} + async fn scoped_ticket_case_update_with_context( State(api): State<WorkspaceApi>, headers: HeaderMap, @@ -16516,6 +16809,20 @@ async fn scoped_ticket_case_update_with_context( path: AxumPath<(String, String)>, request: Json<ticket::TicketCaseUpdate>, ) -> ApiResult<Json<ticket::Ticket>> { + 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()); + } + 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<Option<WorkerMutationSource>> { // 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() { @@ -16551,13 +16858,7 @@ async fn scoped_ticket_case_update_with_context( .into()); } } - if source.is_none() && context.actor.is_none() { - return Err(Error::WorkspacePermissionDenied( - "Ticket case decisions require an authenticated actor".into(), - ) - .into()); - } - apply_ticket_case_update(State(api), source, context.actor.as_ref(), path, request).await + Ok(source) } async fn apply_ticket_case_update( @@ -28087,6 +28388,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, } @@ -32488,6 +32790,10 @@ impl IntoResponse for ApiError { #[cfg(test)] mod tests { + mod assignment_tracking_tests { + include!("server_assignment_tracking_tests.rs"); + } + mod ticket_case_signed_tests { include!("server_ticket_case_signed_tests.rs"); } @@ -37935,6 +38241,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()), @@ -38259,6 +38566,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()), @@ -40293,6 +40601,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()), }), @@ -52922,6 +53231,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()), }), @@ -52934,6 +53244,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()), }), @@ -52946,6 +53257,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, }), @@ -52957,6 +53269,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, }), @@ -61161,6 +61474,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()), }), @@ -61184,6 +61498,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()), }), 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/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/worker_operations_tests.rs b/crates/workspace-server/src/server/tests/worker_operations_tests.rs index 80bfe89bc..2229bdb59 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()), })) }; 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..69cc88c88 --- /dev/null +++ b/crates/workspace-server/src/server_assignment_tracking_tests.rs @@ -0,0 +1,1344 @@ +use super::*; + +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<Body> { + 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<u8>| { + 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<Body> { + 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<Body>, + 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() + ); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 013b0a4e2..458a860b3 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -27,8 +27,17 @@ use crate::backend_job::{ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; +#[path = "assignment_tracking.rs"] +mod assignment_tracking; +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 = 91; +const LATEST_SCHEMA_VERSION: i64 = 92; 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"; @@ -291,6 +300,11 @@ const MIGRATIONS: &[Migration] = &[ 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, + }, ]; #[derive(Clone, Copy)] @@ -1821,6 +1835,38 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ) -> Result<Option<TicketRoleAssignmentRecord>>; /// Retained responsibility, including ended work and removed principals. /// Use the active variants for admission, routing, authorization or removal. + fn update_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result<TicketAssignmentTrackingProjection>; + fn get_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result<Option<TicketAssignmentTrackingProjection>>; + fn list_current_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result<Vec<TicketAssignmentTrackingProjection>>; + fn page_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + after_event_id: Option<&str>, + limit: usize, + ) -> Result<ticket::TicketAssignmentTrackingHistoryPage>; + fn list_ticket_assignment_tracking_history( + &self, + workspace_id: &str, + ticket_id: &str, + assignment_id: &str, + ) -> Result<Vec<TicketAssignmentTrackingHistoryEntry>>; fn list_current_ticket_role_assignments( &self, workspace_id: &str, @@ -1837,6 +1883,27 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ticket_id: &str, role: TicketAssignmentRole, ) -> Result<Option<TicketRoleAssignmentRecord>>; + 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<TicketRoleAssignmentRecord>; + 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<bool>; fn set_current_ticket_role_assignment( &self, record: &TicketRoleAssignmentRecord, @@ -7685,23 +7752,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"))) }) } @@ -7768,6 +7819,55 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn update_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + update: &TicketAssignmentTrackingUpdate, + source: &TicketAssignmentTrackingSourceContext, + ) -> Result<TicketAssignmentTrackingProjection> { + 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<Option<TicketAssignmentTrackingProjection>> { + assignment_tracking::get(self, workspace_id, ticket_id, assignment_id) + } + fn list_current_ticket_assignment_tracking( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result<Vec<TicketAssignmentTrackingProjection>> { + 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<ticket::TicketAssignmentTrackingHistoryPage> { + 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<Vec<TicketAssignmentTrackingHistoryEntry>> { + assignment_tracking::history(self, workspace_id, ticket_id, assignment_id) + } fn list_current_ticket_role_assignments( &self, workspace_id: &str, @@ -7839,216 +7939,65 @@ impl ControlPlaneStore for SqliteWorkspaceStore { operation_id: &str, allow_reassign: bool, ) -> Result<TicketRoleAssignmentRecord> { - 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::<String>(); - 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<TicketRoleAssignmentRecord> { + 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<bool> { 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<String>)> = 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::<rusqlite::Result<Vec<_>>>()?; - 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) }) } @@ -11410,6 +11359,246 @@ fn ensure_worker_assignment_available( Ok(()) } +impl SqliteWorkspaceStore { + 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<TicketRoleAssignmentRecord> { + 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::<String>(); + 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)?; + 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<String>, Option<String>)> = 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); + } + + 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::<rusqlite::Result<Vec<_>>>()?; + 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<TicketRoleAssignmentRecord> { @@ -11527,22 +11716,53 @@ fn clear_current_ticket_role_assignment_in_tx( occurred_at: &str, reason: Option<&str>, fingerprint_domain: &str, +) -> Result<bool> { + 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<bool> { 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, @@ -11571,6 +11791,26 @@ fn clear_current_ticket_role_assignment_in_tx( (Some(agent_key.as_str()), None, None) } }; + let persisted:Option<Option<String>>=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); + } + 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, @@ -13186,7 +13426,34 @@ fn migrate_ticket_cases_v90_to_v91(conn: &Connection) -> Result<()> { 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 create_latest_workspace_schema(conn: &Connection) -> Result<()> { + create_workspace_schema_structure(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 @@ -13204,7 +13471,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; @@ -13274,11 +13544,6 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { for sql in ticket_parent_triggers { conn.execute_batch(&sql)?; } - ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { - Error::Store(format!( - "fresh Workspace Ticket schema verification failed: {error}" - )) - })?; Ok(()) } @@ -14123,6 +14388,7 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> { #[cfg(test)] mod tests { + include!("assignment_tracking_tests.rs"); include!("store_workspace_config_tests.rs"); fn drive_grant_fixture(store: &SqliteWorkspaceStore) -> server_api::DriveGrantResponse { @@ -16019,7 +16285,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(); @@ -16327,6 +16597,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx version: 91, name: "Ticket open/closed cases and retained routing evidence".to_string() }, + WorkspaceSchemaMigrationStep {version:92, name:"Ticket-owned assignment tracking".into()}, ] ); @@ -16428,6 +16699,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (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()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -18839,7 +19111,16 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } fn stored_fixture_rows(conn: &Connection, table: &str) -> Vec<Vec<rusqlite::types::Value>> { - fixture_query_rows(conn, &format!("SELECT * FROM {table} ORDER BY rowid")) + // 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<Vec<rusqlite::types::Value>> { @@ -19009,16 +19290,16 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); let plan = SqliteWorkspaceStore::migration_plan(&path).unwrap(); assert_eq!(plan.current_schema_version, version); - assert_eq!(plan.target_schema_version, 91); + assert_eq!(plan.target_schema_version, LATEST_SCHEMA_VERSION); assert_eq!( plan.migrations .iter() .map(|step| step.version) .collect::<Vec<_>>(), if version == 89 { - vec![90, 91] + vec![90, 91, 92] } else { - vec![91] + vec![91, 92] } ); drop(conn); @@ -19026,13 +19307,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 91); + 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<_>>(), vec![(90, "Runtime binding connection configuration and historical verification archive".into()), - (91, "Ticket open/closed cases and retained routing evidence".into())]); + (91, "Ticket open/closed cases and retained routing evidence".into()), + (92, "Ticket-owned assignment tracking".into())]); for (table, rows) in unchanged.iter().zip(before.iter()) { assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); } @@ -19190,7 +19472,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let reopened = SqliteWorkspaceStore::open(&path).unwrap(); reopened .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 91); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!(fixture_auth_rows(conn), auth_before); Ok(()) }) @@ -19420,7 +19702,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } for store in [&fresh, &upgraded] { store.with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 91); + 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(); @@ -20699,30 +20981,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<_>>(), + 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) diff --git a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs index 72c97c6d8..0f1e65591 100644 --- a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs +++ b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs @@ -265,10 +265,20 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64 drop(conn); let store = SqliteWorkspaceStore::open(&path).unwrap(); store.with_conn(|conn| { - assert_eq!(current_schema_version(conn)?,91); - assert_eq!(conn.query_row("SELECT MAX(version) FROM ticket_schema_migrations",[],|row|row.get::<_,i64>(0))?,10); + assert_eq!(current_schema_version(conn)?,92); + 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) { assert_eq!(&stored_fixture_rows(conn,table),rows,"preserve {table}"); } + 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); @@ -483,14 +493,14 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history let store = SqliteWorkspaceStore::open(&path).unwrap(); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 91); + assert_eq!(current_schema_version(conn)?, 92); assert_eq!( conn.query_row( "SELECT MAX(version) FROM ticket_schema_migrations", [], |row| row.get::<_, i64>(0) )?, - 10 + 11 ); assert_eq!( stored_fixture_rows(conn, "typed_ticket_case_history"), diff --git a/crates/workspace-server/src/worker_source/runtime_request.rs b/crates/workspace-server/src/worker_source/runtime_request.rs index 5e8b4a619..9e79fd3b1 100644 --- a/crates/workspace-server/src/worker_source/runtime_request.rs +++ b/crates/workspace-server/src/worker_source/runtime_request.rs @@ -12,6 +12,9 @@ use crate::store::ControlPlaneStore; pub struct VerifiedRuntimeRequestSource { pub runtime_id: String, pub worker_id: Option<String>, + /// 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<String>, } @@ -273,5 +276,6 @@ pub(crate) async fn verify_runtime_request_source_proof_with_clock( token_id_hash: Some(diagnostic_id_hash(&claims.jti)), runtime_id: claims.iss, worker_id: claims.worker_id, + authentication_binding_id: trusted.binding_id, }) } diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 0bb170c59..f81403e7e 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -52,6 +52,8 @@ 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", @@ -464,15 +466,21 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { } let workspace_id = operation.response.workspace_id.clone(); - // Case judgments and legacy snapshots are immutable under ordinary - // Ticket operations, including Ticket deletion and FK cascades. Only - // this owner-authorized whole-Workspace purge may discard them. + // 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 case_delete_guards = Vec::new(); + 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"), + ("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"), ] { @@ -482,7 +490,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { |row| row.get(0), )?; tx.execute_batch(&format!("DROP TRIGGER \"{trigger}\";"))?; - case_delete_guards.push(definition); + ticket_delete_guards.push(definition); } for table in WORKSPACE_DELETION_PURGE_TABLES { tx.execute( @@ -499,7 +507,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { "Workspace disappeared before deletion finalized".to_string(), )); } - for definition in case_delete_guards { + for definition in ticket_delete_guards { tx.execute_batch(&definition)?; } let now = Utc::now().to_rfc3339(); @@ -978,6 +986,57 @@ mod tests { .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_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, @@ -1022,7 +1081,7 @@ mod tests { } let assert_immutable = |workspace: &str| { store.with_conn(|conn| { - for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot"] { + 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()); } @@ -1052,8 +1111,13 @@ mod tests { "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<String> = store + let other_before: Vec<Option<String>> = store .with_conn(|conn| { tables .iter() @@ -1095,7 +1159,12 @@ mod tests { [], |row| row.get(0), )?; - assert_eq!(count, 1, "rollback must restore {table}"); + 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(()) @@ -1132,7 +1201,7 @@ mod tests { |row| row.get(0), )?; assert_eq!(count, 0, "purge must remove {table}"); - let after: String = conn.query_row( + let after: Option<String> = conn.query_row( &format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"), [], |row| row.get(row.as_ref().column_count() - 1), diff --git a/docs/development/ticket-assignment-tracking.md b/docs/development/ticket-assignment-tracking.md new file mode 100644 index 000000000..fe71acde3 --- /dev/null +++ b/docs/development/ticket-assignment-tracking.md @@ -0,0 +1,47 @@ +# Ticket assignment tracking contract (T-741) + +## Authority and identities + +Ticket component schema 11 owns assignment identities, current pointers, assignment audit/receipts, and the self-reported handling data. The historical physical names `ticket_worker_assignments`, `ticket_current_worker_assignments`, `ticket_worker_assignment_events`, and `ticket_assignment_operations` are retained; they are not another Workspace assignment authority. Workspace schema 92 composes these Ticket-owned definitions and keeps host principal/resource constraints. + +An immutable `assignment_id` identifies one delegation. Progress updates modify that assignment row; a new Assign/reissue operation creates another row even for the same principal. An exact retry returns the saved result, not a new delegation or a fresh observation. + +The Orchestrator principal is `workspace_agent:workspace-orchestrator`. Backend resolves its current registered Worker. Worker stop, idle, restore, replacement, MR changes and notification delivery do not update handling status or create assignment rows. + +Worker creation reservations, attachment/grant recovery, actual unfinished work, and identity-specific work release remain operational responsibilities. Handling `done` is not work release, Worker termination, resource cleanup, MR approval/integration, or a Ticket close judgment. + +## Self-report and concurrency + +The handling payload contains `status` (`waiting`, `inprogress`, `done`) and nullable `waiting_reason`, `progress`, `next_action`, and `result`. Assign records initial waiting/initial judgment pending together with the current pointer, audit event, and receipt in one transaction. + +The update input is `assignment_id`, `expected_last_event_id`, `operation_key`, and the complete handling payload. The observed event belongs to that exact delegation; comparing status alone would miss concurrent changes. No ordered status progression, MR or approval requirement applies. Direct waiting-to-done is valid. + +Backend constructs authenticated actor/source context; model input cannot supply role, actor, Runtime or Worker authority. The mutation transaction validates current singleton ownership and the Runtime binding actually used to verify the request. Full input/source must match an existing receipt. The receipt check distinguishes an old operation retry from a new update to a released/replaced delegation; only the latter requires current assignment/event CAS. Worker replacement does not let the old sender replay operations. + +The public Orchestrator Assign/clear paths also bind receipts to Backend-authenticated Runtime/Worker/agent/binding context or the authenticated account/user pair, separately from readable Assign/audit actor text. Runtime calls fence singleton and binding inside the write transaction even for retries. Account authoring retains its authenticated Workspace permission boundary and does not require a Runtime identity. Migration preserves old receipts without inventing a historical binding proof. + +Close/reopen does not change handling status. Current Orchestrator result recording remains authorized after close without requiring an active-worker assignment or restoring old execution grants. Reopen by itself creates neither a new delegation nor old Worker execution rights. + +## Public surfaces + +Canonical Workspace endpoints: + +- `GET /workspaces/{workspace_id}/tickets/{ticket_id}/assignment-tracking`: current Orchestrator assignment projection. Optional `assignment_id` selects a retained assignment, including its last handling and independent release/replacement audit. +- `POST` on the same path: self-report update with event CAS and exact retry key. +- `GET .../assignment-tracking/history`: assignment-scoped handling audit, with `assignment_id`, optional `after_event_id`, and `limit` (1–100). The returned `next_event_id` continues that history. + +Normal tools and route-bound WIP operations expose `TicketAssignmentTrackingQuery`, `TicketAssignmentTrackingHistory`, and `TicketAssignmentTrackingUpdate`. Ticket query/detail and model projections include current assignment tracking. Read access does not grant mutation authority; Backend checks every update. + +Read a fresh projection before a new update. Do not treat a replayed receipt's last-event identity as the latest observed value. Retained-assignment query exposes Assign attribution and release/replacement independently; the history endpoint pages initial/migrated handling and subsequent self-reports, not delivery acknowledgements. + +Update identities and operation keys are nonempty, control-character-free and bounded to 1024 bytes. Each optional handling text is bounded to 16384 UTF-8 bytes and cannot contain NUL. History cursors must belong to the same Workspace, Ticket and assignment. Read-only tool access includes query/history, never update; enabling a tool is not Backend authorization. + +## Migration and downstream handoff + +Ticket 10→11 and Workspace 91→92 append migrations; applied migrations are not rewritten. Original workflow snapshots, Queue assignment fences, assignment identities/audit/receipts and subsequent case decisions are retained and reconciled. Current fenced queued/in-progress delegation can be retained with its migration evidence. Historical, ambiguous, or superseded relationships retain original data plus diagnostics with unknown handling (`tracking: null`); neither successful done nor a fresh execution request is inferred. + +For retained current relationships with unknown handling, an existing matching Assign audit event can serve as the observed `last_event_id` baseline for the first explicit self-report, including after close. This is not a new handling event or an initial waiting value. The first self-report replaces that baseline with its own event; concurrent updates then fail normal CAS. If original Assign audit is missing, keep the diagnostic rather than inventing an event. Handling-history cursors remain identities returned by that history, not this Assign baseline. + +T-742 consumes this authority for delivery/acceptance/reconsideration cursors and startup/create/restore/idle scans. It must distinguish committed new Assign events from legacy unknown relationships, and bind actual acceptance/start reservations to current assignment identity. It owns replacement of the old Queue/state routing predicate and unfinished-dependency start refusals. Waiting alone is not a reason for repeated delivery, and updating one's own progress must not become an input busy loop. + +T-743 owns the final UI/Prompt operation model. Classification, Ticket case state, handling, delivery/consideration position, and actual Worker/grant lifecycle remain separate. This contract does not implement live DB migration or update the running environment. 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..4f68f1c84 --- /dev/null +++ b/docs/report/2026-10-11-subworker-advisory-surface-gap.md @@ -0,0 +1,15 @@ +# 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の全環境に同機能がないと断定するものではない。 diff --git a/openapi/server-api.json b/openapi/server-api.json index 65f364853..78819a744 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" @@ -10639,6 +10645,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", @@ -10674,6 +10796,239 @@ ], "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, + "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" + }, "TicketAttention": { "enum": [ "unresolved_review", @@ -10957,6 +11312,12 @@ }, "type": "array" }, + "assignment_tracking": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, "assignments": { "items": { "$ref": "#/components/schemas/TicketRoleAssignmentSummary" @@ -11159,6 +11520,7 @@ "linked_objectives", "implementation_reports", "assignments", + "assignment_tracking", "assignment_diagnostics", "action_eligibility", "merge_requests", @@ -12246,6 +12608,12 @@ "TicketQueryItem": { "additionalProperties": false, "properties": { + "assignment_tracking": { + "items": { + "$ref": "#/components/schemas/TicketAssignmentTrackingProjection" + }, + "type": "array" + }, "blocker_count": { "format": "uint", "maximum": 9007199254740991, @@ -12423,6 +12791,7 @@ "blocker_count", "unresolved_blocker_count", "unresolved_review_count", + "assignment_tracking", "evidence", "merge_requests" ], @@ -16444,7 +16813,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3" + "x-yoi-source-digest": "sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774" }, "openapi": "3.1.0", "paths": { @@ -38507,6 +38876,345 @@ } } }, + "/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/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", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 1d5aacf2f..88c4da3bb 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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // 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 59abffb71..a3f8fc78e 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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // 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 3acfbbd50..7a78b1505 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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // 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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 0983808b9..60e817c7a 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,10 +1,12 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // 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 Array_of_TicketAssignmentTrackingProjection = Array<TicketAssignmentTrackingProjection>; + export type Array_of_WorkspaceTag = Array<WorkspaceTag>; export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; @@ -87,10 +89,28 @@ export type TicketActionEligibility = { blockers: Array<string>; can_assign_orch 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 TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: 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 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; }; @@ -103,7 +123,7 @@ export type TicketClosureJudgment = { at: string; author: string; explanation: s 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>; 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 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; }; @@ -133,7 +153,7 @@ 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 = { 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 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 = { attention?: Array<TicketAttention>; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; 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; }; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index d250b63c9..8db0ab85e 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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) // 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 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 1069c0a2f..23735a03b 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -139,6 +139,7 @@ function ticketDetailFixture() { artifact_count: 0, artifacts: [], assignment_diagnostics: [], + assignment_tracking: [], assignments: [], closure_history: [], closure_operation_key: null, @@ -195,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); diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index 778be9a84..6b2135702 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -20,7 +20,10 @@ import type { ReviewFinding, TicketActionEligibility, TicketAssignmentPrincipal, + TicketAssignmentRelease, TicketAssignmentSummary, + TicketAssignmentTracking, + TicketAssignmentTrackingProjection, TicketCaseAction, TicketCaseHistoryEntry, TicketClosureJudgment, @@ -649,6 +652,130 @@ function parseTicketRoleAssignmentRecord( }; } +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`), + }; +} + +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, @@ -961,6 +1088,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { "artifact_count", "artifacts", "assignment_diagnostics", + "assignment_tracking", "assignments", "closure_history", "body", @@ -1012,6 +1140,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`, From 21df210a7fd580cd2d14a2dd92b2479579fa2288 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 02:56:42 +0900 Subject: [PATCH 12/39] fix: bound Worker protocol backpressure and connection ownership --- crates/worker-runtime/src/worker_backend.rs | 68 +- crates/workspace-server/src/server.rs | 161 ++- .../tests/protocol_backpressure_tests.rs | 920 ++++++++++++++++++ .../src/server/tests/worker_protocol_tests.rs | 210 ++++ .../workspace_protocol_backpressure_tests.rs | 140 +++ .../src/server/worker_operations.rs | 48 +- .../src/server/worker_protocol.rs | 247 +++++ .../src/workspace_subscription.rs | 392 +++++--- .../worker-protocol-backpressure.md | 112 +++ 9 files changed, 2036 insertions(+), 262 deletions(-) create mode 100644 crates/workspace-server/src/server/tests/protocol_backpressure_tests.rs create mode 100644 crates/workspace-server/src/server/tests/worker_protocol_tests.rs create mode 100644 crates/workspace-server/src/server/tests/workspace_protocol_backpressure_tests.rs create mode 100644 crates/workspace-server/src/server/worker_protocol.rs create mode 100644 docs/development/worker-protocol-backpressure.md diff --git a/crates/worker-runtime/src/worker_backend.rs b/crates/worker-runtime/src/worker_backend.rs index 63a4e8163..436131647 100644 --- a/crates/worker-runtime/src/worker_backend.rs +++ b/crates/worker-runtime/src/worker_backend.rs @@ -2537,16 +2537,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, + ) + }) } } } @@ -7898,6 +7906,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<AtomicBool>); + 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::ListRewindTargets) + .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/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index d391d9f33..cb8094c83 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -2,9 +2,12 @@ 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; @@ -29083,51 +29086,19 @@ 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<protocol::Method>, - pub(crate) events: tokio::sync::mpsc::Receiver<protocol::Event>, -} - pub(crate) async fn connect_workspace_worker_protocol( api: &WorkspaceApi, worker: &RuntimeWorkerRef, @@ -29199,39 +29170,21 @@ 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( @@ -29249,47 +29202,48 @@ async fn connect_embedded_worker_protocol( 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; } + 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 { .. }); + 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 let Err(reason) = enqueue_worker_protocol_event(&events, event) { return reason; } + if terminal { return WorkerProtocolEnd::Disconnected; } + } + } + Err(error) => { + let terminal = embedded_protocol_dispatch_is_terminal(&error, &transport); + 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; } } + // Stop, disconnect and upstream lag never cause an implicit reattach. + 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( @@ -32502,6 +32456,7 @@ mod tests { mod drive_tests; mod drive_web_roundtrip_tests; mod external_checkout_tests; + mod protocol_backpressure_tests; mod subject_spawn_tests; mod subjektiv_jobs_tests; mod ticket_evidence_tests; @@ -32511,7 +32466,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}; 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..7a4066b39 --- /dev/null +++ b/crates/workspace-server/src/server/tests/protocol_backpressure_tests.rs @@ -0,0 +1,920 @@ +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(), + ) + .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(), + ) + .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()) + .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()) + .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<protocol::Method>, +) { + 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::<WorkerProtocolEnd>().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<protocol::Method>, +) { + 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<WsMessage>, + written: Vec<WsMessage>, +} + +impl futures::Stream for AdmissionCancellationSocket { + type Item = std::result::Result<WsMessage, axum::Error>; + + fn poll_next( + mut self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll<Option<Self::Item>> { + match self.inbound.take() { + Some(message) => std::task::Poll::Ready(Some(Ok(message))), + None => std::task::Poll::Pending, + } + } +} + +impl futures::Sink<WsMessage> 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::result::Result<(), Self::Error>> { + 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::result::Result<(), Self::Error>> { + std::task::Poll::Ready(Ok(())) + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll<std::result::Result<(), Self::Error>> { + 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<tokio::sync::oneshot::Sender<()>>, + dropped: Option<tokio::sync::oneshot::Sender<()>>, + written: Arc<std::sync::Mutex<Vec<WsMessage>>>, +} + +impl futures::Stream for StalledBrowserOutput { + type Item = std::result::Result<WsMessage, axum::Error>; + + fn poll_next( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll<Option<Self::Item>> { + std::task::Poll::Pending + } +} + +impl futures::Sink<WsMessage> 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::result::Result<(), Self::Error>> { + 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::result::Result<(), Self::Error>> { + std::task::Poll::Pending + } + + fn poll_close( + self: std::pin::Pin<&mut Self>, + _cx: &mut std::task::Context<'_>, + ) -> std::task::Poll<std::result::Result<(), Self::Error>> { + 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()); + 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(), + ) + .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(start_paused = true)] +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/worker_protocol_tests.rs b/crates/workspace-server/src/server/tests/worker_protocol_tests.rs new file mode 100644 index 000000000..a66c68322 --- /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<oneshot::Sender<()>>, + dropped: Option<oneshot::Sender<()>>, + sent: std::sync::Arc<std::sync::Mutex<Vec<TungsteniteMessage>>>, +} +impl futures::Sink<TungsteniteMessage> for StalledWire { + type Error = tokio_tungstenite::tungstenite::Error; + fn poll_ready( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll<std::result::Result<(), Self::Error>> { + 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<std::result::Result<(), Self::Error>> { + Poll::Pending + } + fn poll_close( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll<std::result::Result<(), Self::Error>> { + 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<std::sync::Mutex<Vec<TungsteniteMessage>>>, +) { + 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::ListRewindTargets) + .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_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<WsMessage>, + entered: Option<oneshot::Sender<()>>, + dropped: Option<oneshot::Sender<()>>, +} +impl futures::Stream for CongestedBrowser { + type Item = std::result::Result<WsMessage, axum::Error>; + fn poll_next(mut self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Option<Self::Item>> { + self.inbound.poll_recv(cx).map(|message| message.map(Ok)) + } +} +impl futures::Sink<WsMessage> for CongestedBrowser { + type Error = std::convert::Infallible; + fn poll_ready( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll<std::result::Result<(), Self::Error>> { + 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<std::result::Result<(), Self::Error>> { + Poll::Pending + } + fn poll_close( + self: Pin<&mut Self>, + _: &mut Context<'_>, + ) -> Poll<std::result::Result<(), Self::Error>> { + 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 fe84fb7f2..35b60148f 100644 --- a/crates/workspace-server/src/server/worker_operations.rs +++ b/crates/workspace-server/src/server/worker_operations.rs @@ -470,10 +470,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, }) } @@ -485,12 +486,27 @@ impl WorkspaceWorker { pub(crate) struct WorkspaceWorkerMethodSender { worker: WorkspaceWorker, methods: tokio::sync::mpsc::Sender<protocol::Method>, + pub(super) termination: WorkerProtocolTermination, } pub(crate) struct WorkspaceWorkerConnection { pub(crate) sender: WorkspaceWorkerMethodSender, pub(crate) events: tokio::sync::mpsc::Receiver<protocol::Event>, } impl WorkspaceWorkerMethodSender { + pub(crate) fn end_status(&self) -> tokio::sync::watch::Receiver<Option<WorkerProtocolEnd>> { + self.termination.subscribe() + } + pub(super) fn new( + worker: WorkspaceWorker, + methods: tokio::sync::mpsc::Sender<protocol::Method>, + termination: WorkerProtocolTermination, + ) -> Self { + Self { + worker, + methods, + termination, + } + } pub(crate) async fn send( &self, context: &WorkerOperationContext, @@ -604,15 +620,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..c1a5ed9e0 --- /dev/null +++ b/crates/workspace-server/src/server/worker_protocol.rs @@ -0,0 +1,247 @@ +//! Bounded operational protocol connections. Queue admission never waits for delivery. +//! Overflow ends the connection: reconnect/resubscribe with a fresh snapshot, never replay +//! a method whose transport delivery or execution outcome may be unknown. +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<Option<WorkerProtocolEnd>>, + _pump: std::sync::Arc<WorkerProtocolPump>, +} +impl WorkerProtocolTermination { + pub(crate) fn subscribe(&self) -> watch::Receiver<Option<WorkerProtocolEnd>> { + self.state.subscribe() + } + pub(crate) fn reason(&self) -> Option<WorkerProtocolEnd> { + *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<Option<WorkerProtocolEnd>>, reason: WorkerProtocolEnd) { + state.send_if_modified(|current| { + if current.is_some() { + return false; + } + *current = Some(reason); + true + }); +} +async fn ended(state: &mut watch::Receiver<Option<WorkerProtocolEnd>>) -> 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<protocol::Method>, + pub(crate) events: mpsc::Receiver<protocol::Event>, + pub(crate) termination: WorkerProtocolTermination, +} + +pub(super) fn spawn_worker_protocol_pump<F, Fut>(pump: F) -> WorkspaceWorkerProtocolConnection +where + F: FnOnce(mpsc::Receiver<protocol::Method>, mpsc::Sender<protocol::Event>) -> Fut, + Fut: Future<Output = WorkerProtocolEnd> + 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<protocol::Event>, + 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<S, R>( + mut sink: S, + mut stream: R, +) -> WorkspaceWorkerProtocolConnection +where + S: Sink<TungsteniteMessage> + Unpin + Send + 'static, + R: Stream< + Item = std::result::Result<TungsteniteMessage, tokio_tungstenite::tungstenite::Error>, + > + 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<S>( + mut connection: worker_operations::WorkspaceWorkerConnection, + mut socket: S, + context: WorkerOperationContext, +) where + S: Stream<Item = std::result::Result<WsMessage, axum::Error>> + Sink<WsMessage> + 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<S: Sink<WsMessage> + 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/workspace_subscription.rs b/crates/workspace-server/src/workspace_subscription.rs index 9e7698a30..8b0e0b818 100644 --- a/crates/workspace-server/src/workspace_subscription.rs +++ b/crates/workspace-server/src/workspace_subscription.rs @@ -1,6 +1,6 @@ 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 +12,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<WorkspaceWorkerMethodSender>, } -pub(crate) async fn serve_workspace_subscription( +pub(crate) async fn serve_workspace_subscription<S>( api: WorkspaceApi, - socket: WebSocket, + socket: S, context: WorkerOperationContext, -) { +) where + S: futures::Stream<Item = Result<WsMessage, axum::Error>> + + futures::Sink<WsMessage> + + 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::<WsMessage>(OUTBOUND_CAPACITY); let (protocol_outbound, mut protocol_receiver) = mpsc::channel::<WsMessage>(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 +56,179 @@ 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::<SubscriptionId, ActiveSubscription>::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::<SubscriptionFrame>(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::<SubscriptionFrame>(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( + &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, + 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 +257,7 @@ async fn run_worker_protocol( runtime_id: String, worker_id: protocol::subscription::SubscriptionWorkerId, mut events: mpsc::Receiver<protocol::Event>, + end_status: tokio::sync::watch::Receiver<Option<WorkerProtocolEnd>>, control_outbound: mpsc::Sender<WsMessage>, protocol_outbound: mpsc::Sender<WsMessage>, ) { @@ -313,8 +349,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(), }, )), ) @@ -646,6 +682,7 @@ mod tests { "runtime-1".to_string(), worker_id(), events, + tokio::sync::watch::channel(None).1, control_outbound, protocol_outbound, ) @@ -660,6 +697,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/docs/development/worker-protocol-backpressure.md b/docs/development/worker-protocol-backpressure.md new file mode 100644 index 000000000..c7a22264c --- /dev/null +++ b/docs/development/worker-protocol-backpressure.md @@ -0,0 +1,112 @@ +# Worker operational protocol backpressure + +The Workspace Worker operational connection is separate from Workspace request +admission (T-732). Both the dedicated Worker WebSocket and the Workspace +multiplexer use the identity-bound `WorkspaceWorkerMethodSender` and the same +remote/embedded bridge. This does not change Ticket assignment, general input +routing, or database execution placement. + +## Ownership and admission + +- Each bridge owns one captured remote WebSocket or embedded execution endpoint. + It never follows a restored execution under the same Worker identity. +- Methods have 256 queue slots; events have 512. Browser authorization, input + source attribution, interactive-Worker checks, and session exclusion remain at + method admission. Queue admission is `try_send`, not a transport-capacity wait + under the session guard. Success here means **queued**, not delivered/executed. +- Event enqueue never waits for its consumer. Snapshot is first; direct replies + and live events retain the existing bridge ordering. No additional event pump + can reorder them. Overflow ends the connection rather than skipping an event + and continuing an uncertain stream. +- The first terminal cause wins: method overflow, event overflow/lag, transport + timeout, or disconnect. Terminal state cannot reopen when capacity becomes + available. It preempts pending browser admission and remote transport waits. +- The last client capability owns an abort-on-drop pump handle. The pump does not + retain that capability, avoiding an ownership cycle. Event-consumer closure + also cancels the pump even when method capabilities remain. Cancellation drops + the bounded channels and captured transport; it does not stop the Worker. + +A rejected full-queue method is known **not admitted to that queue**. Previously +queued methods, interrupted wire sends, and execution calls which have begun may +have unknown delivery or side effects. They are never converted to success or a +claim of non-execution, and are never automatically retried. Cancel is subject to +this same rule: it either enters the bounded queue or the connection explicitly +ends; there is no silent special-case drop or unbounded control lane. + +## Transport deadlines and termination + +Remote handshake and sends, browser writes (including Pong/error), and Workspace +multiplexer writes have 10-second deadlines. The dedicated browser session makes +one separately bounded Close attempt (at most another 10 seconds), with code 1011 +and a short reason requiring snapshot resynchronization and warning of unknown +pending delivery. A completely blocked wire cannot guarantee delivery of the +Close/error frame; releasing the socket is the terminal signal in that case. + +The multiplexer retains separate bounded control and high-volume protocol lanes +and control priority. Writer termination cancels the receive/admission loop, even +if that loop is awaiting control capacity or session ownership. Receive exit +aborts/joins the writer and subscription tasks instead of waiting indefinitely +for a slow writer to drain. Caller cancellation has abort-on-drop ownership. +Bridge overflow is exposed as `SubscriptionClosed { code: Lagged }`, not normal +resource disappearance, when the terminal control frame can be delivered. + +Embedded dispatch is an existing synchronous Runtime API, not a new database or +executor redesign. Its connected-method adapter wait uses the existing +10-second cancellation-and-join waiter rather than leaving a detached send after +timeout. Existing completion and lifecycle ownership/fencing are retained. A +synchronous embedded dispatch already begun is not immediately preemptible by +browser disconnect; its bounded adapter wait settles before endpoint exclusion +is released. Cancellation/join is resource reclamation, **not rollback of an +already delivered method**. + +## Recovery + +Reconnect explicitly, resubscribe where using the multiplexer, and replace local +state with the fresh snapshot before live delivery (T-415). Do not replay old +method queues or transient protocol deltas. An ended connection never implicitly +Restores, reattaches, or retargets the Worker. Slow connections do not hold Server +session exclusion during transport waits, so other connections and Stop/session +cleanup can proceed. + +## Evidence and limits + +The initial static audit used `develop@a9a67fd6`; the same wait edges were present +at `develop@98023b98`. This is not evidence of a production incident. Controlled +fixtures exercise the actual remote connector, embedded bridge, browser method +admission/session, and Workspace multiplexer. Reinstating only the old bounded +queue waits makes the remote/embedded overflow and browser session-exclusion +fixtures time out; the corrected paths terminate without guessed sleeps. + +Fixtures use channel/barrier ownership, controllable sinks, loopback WebSockets, +and paused time for transport deadlines. They cover ordered admitted prefixes, +auth rejection before admission, Cancel overflow, pending admission cancellation, +unknown wire delivery without retry, same-Worker fresh connection/Stop, and +transport/channel reclamation. They are not full browser/Runtime process E2E or a +production load benchmark. Existing attach/stop/restore and serialization tests +remain required alongside these regressions. + +### Implementation validation (T-747) + +- `cargo check --offline` at Workspace root: passed (normal TUI/Server closure). +- `cargo test -p yoi-workspace-server --offline --lib protocol_`: 29 passed, + including the real bridges, browser admission/ownership, and stalled multiplexer. +- `cargo test -p yoi-workspace-server --offline`: 867 library tests passed, one + existing ignored test, 11 binary tests passed, including the lag-code wire + classification matrix (`--lib workspace_subscription::tests`: 4 passed). +- `cargo test -p worker-runtime --offline`: 416 library and 19 tests in each of + the two binaries passed, including connected admission cancellation/join and + existing endpoint fencing/lifecycle regressions. +- `cargo test -p protocol --offline`: 80 passed (wire contracts unchanged). +- `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 checker reported 0 unapproved / 0 stale exceptions. + +Additional Runtime compile probes with `--no-default-features`, with only +`http-server,fs-store`, and with only `ws-server` failed in pre-existing feature +boundaries (respectively missing gated request dependencies, missing futures +imports/dependency, and missing filesystem-backed retention methods). Replacing +only the changed Runtime file with its exact `98023b98` content produced the same +error groups in all three probes. These configurations are not claimed validated; +this patch does not repair their unrelated feature wiring. The normal +`ws-server,fs-store` configuration is covered by root check and whole-crate tests. + From 45d8f4d69fce2bf3d4b9fd059ebd80ffc6241300 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 02:57:11 +0900 Subject: [PATCH 13/39] docs: normalize Worker protocol contract formatting --- docs/development/worker-protocol-backpressure.md | 1 - 1 file changed, 1 deletion(-) diff --git a/docs/development/worker-protocol-backpressure.md b/docs/development/worker-protocol-backpressure.md index c7a22264c..e97becf04 100644 --- a/docs/development/worker-protocol-backpressure.md +++ b/docs/development/worker-protocol-backpressure.md @@ -109,4 +109,3 @@ only the changed Runtime file with its exact `98023b98` content produced the sam error groups in all three probes. These configurations are not claimed validated; this patch does not repair their unrelated feature wiring. The normal `ws-server,fs-store` configuration is covered by root check and whole-crate tests. - From 96e5ceb4fb581da8bd3e6deb1460ef9abe5fff2d Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 03:20:02 +0900 Subject: [PATCH 14/39] fix(ticket): replay Assign receipts before new work constraints --- crates/workspace-server/src/server.rs | 23 +-- .../src/server_assignment_tracking_tests.rs | 157 ++++++++++++++++++ crates/workspace-server/src/store.rs | 16 ++ ...26-10-11-subworker-advisory-surface-gap.md | 8 + 4 files changed, 187 insertions(+), 17 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 81249d6d7..402c3c24c 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -13853,21 +13853,6 @@ async fn apply_set_ticket_assignment( }; let assignment = match role { TicketAssignmentRole::Orchestrator => { - 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()); - } if let Some(source) = source.as_ref() { api.store.set_current_ticket_orchestrator_assignment( &record, @@ -48321,8 +48306,10 @@ mod tests { 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()), @@ -49147,6 +49134,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, @@ -49156,7 +49146,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(), diff --git a/crates/workspace-server/src/server_assignment_tracking_tests.rs b/crates/workspace-server/src/server_assignment_tracking_tests.rs index 69cc88c88..138fae02b 100644 --- a/crates/workspace-server/src/server_assignment_tracking_tests.rs +++ b/crates/workspace-server/src/server_assignment_tracking_tests.rs @@ -1,5 +1,162 @@ 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<u8>| { + 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("PUT", &worker_path, worker_input), + 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 + ); + // Only a genuinely new operation is refused; a receipt never restores + // the cleared current pointer, initializes tracking, or starts work. + tracking_response( + app, + request("PUT", &path, input("new-assign")), + StatusCode::CONFLICT, + ) + .await; + assert_eq!(counts(), before); + 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!( + api.store + .get_current_ticket_role_assignment( + TEST_WORKSPACE_ID, + &ticket, + TicketAssignmentRole::Orchestrator + ) + .unwrap() + .is_none() + ); + } +} + fn tracking_path(ticket: &str) -> String { format!("/workspaces/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-tracking") } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 458a860b3..c88faeec8 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -11456,6 +11456,22 @@ impl SqliteWorkspaceStore { return Ok(persisted); } + // This is a constraint on a new delegation, not on a saved result. + // Authenticate the source and resolve its full-input receipt first; + // work that began after the original Assign must not block replay. + if record.role == TicketAssignmentRole::Orchestrator { + let active_worker: 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![record.workspace_id, record.ticket_id], + |row| row.get(0), + )?; + if active_worker { + return Err(Error::TicketAssignmentConflict( + "Orchestrator assignment conflicts with an active Worker assignment".into(), + )); + } + } 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( diff --git a/docs/report/2026-10-11-subworker-advisory-surface-gap.md b/docs/report/2026-10-11-subworker-advisory-surface-gap.md index 4f68f1c84..bbfa5e815 100644 --- a/docs/report/2026-10-11-subworker-advisory-surface-gap.md +++ b/docs/report/2026-10-11-subworker-advisory-surface-gap.md @@ -13,3 +13,11 @@ T-741のdomain/store担当とserver認可/router担当を並行委任した。Wo - 付与しない場合は、短い契約確定チェックポイントを分担の最初に設けることが現状の回避策になる。これは一般的な自動停止・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後の返却・再開方法も、許容値とともに明示すると扱いやすい。 From 26434bb8676b582bafa9d9617c7de3c8719f518c Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 05:13:53 +0900 Subject: [PATCH 15/39] feat: route Orchestrator delivery and Worker starts from Ticket Assign --- crates/server-api/src/lib.rs | 14 + .../src/feature/builtin/manage_worker.rs | 14 + .../src/feature/builtin/orchestration.rs | 24 +- crates/worker/src/prompt/catalog.rs | 56 +- .../src/assignment_delivery.rs | 380 ++++ .../src/assignment_delivery_tests.rs | 794 ++++++++ .../src/assignment_tracking_tests.rs | 9 +- crates/workspace-server/src/authority.rs | 96 +- crates/workspace-server/src/hosts.rs | 5 + crates/workspace-server/src/server.rs | 1763 ++++++----------- .../server/tests/ticket_worker_claim_tests.rs | 1 + .../tests/ticket_worker_launch_tests.rs | 56 +- .../src/server_assignment_attention_tests.rs | 1073 ++++++++++ .../src/server_assignment_delivery_tests.rs | 962 +++++++++ .../src/server_assignment_start_tests.rs | 752 +++++++ .../src/server_assignment_tracking_tests.rs | 27 +- crates/workspace-server/src/store.rs | 455 +++-- .../src/ticket_tag_upgrade_tests.rs | 4 +- .../src/workspace_deletion.rs | 10 + .../development/ticket-assignment-delivery.md | 60 + openapi/server-api.json | 23 +- .../workspace_orchestrator_queue_attention.md | 15 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 4 +- web/workspace/src/lib/generated/ticket-api.ts | 4 +- .../src/lib/generated/worker-launch-api.ts | 4 +- 27 files changed, 5226 insertions(+), 1383 deletions(-) create mode 100644 crates/workspace-server/src/assignment_delivery.rs create mode 100644 crates/workspace-server/src/assignment_delivery_tests.rs create mode 100644 crates/workspace-server/src/server_assignment_attention_tests.rs create mode 100644 crates/workspace-server/src/server_assignment_delivery_tests.rs create mode 100644 crates/workspace-server/src/server_assignment_start_tests.rs create mode 100644 docs/development/ticket-assignment-delivery.md diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 3acc5956a..87eaa3a01 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -1977,6 +1977,7 @@ pub trait ServerApi { )] async fn runtime_worker_create_alias( &self, + #[extension] context: ServerRequestContext, #[path] runtime_id: String, #[body] request: RuntimeWorkerSpawnRequest, ) -> Result<RuntimeWorkerSpawnResponse, RepositoryApiError>; @@ -2006,6 +2007,7 @@ pub trait ServerApi { )] async fn runtime_worker_create( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] runtime_id: String, #[body] request: RuntimeWorkerSpawnRequest, @@ -7940,6 +7942,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<String>, + pub operation_id: String, pub principal: TicketAssignmentPrincipal, pub expected_assignment_id: Option<String>, @@ -8580,6 +8586,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<String>, + pub ticket_id: String, pub operation_id: String, } @@ -9909,6 +9919,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<String>, + pub ticket_id: String, pub operation_id: String, } 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<String>, + pub runtime_id: String, /// Ordered alias-keyed selections forwarded as canonical Workdir attachments. pub workdir_attachments: Vec<WorkerLifecycleWorkdirAttachment>, @@ -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<String>, + /// Observed current Orchestrator delegation, required for a Ticket start by the Orchestrator. + #[serde(default)] + orchestrator_assignment_id: Option<String>, #[serde(default)] display_name: Option<String>, /// 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/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index abbdca4e2..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<String>, 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, @@ -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", @@ -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!( @@ -475,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"), diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index cdb30cfae..4d2497661 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<String>, + input_event_id: Option<String>, + waiting_reason: Option<String>, + next_action: Option<String>, } 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, @@ -841,8 +865,10 @@ 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:")); + // TUI's final operation model is a separate downstream scope; both + // renderers still consume the same bounded resource-key projection. + assert!(tui.starts_with("Queued Tickets require attention:")); 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 +885,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/workspace-server/src/assignment_delivery.rs b/crates/workspace-server/src/assignment_delivery.rs new file mode 100644 index 000000000..db59d0f51 --- /dev/null +++ b/crates/workspace-server/src/assignment_delivery.rs @@ -0,0 +1,380 @@ +//! Durable operational delivery and startup fencing, independent of handling. +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<String>, + pub next_action: Option<String>, +} + +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.at<h.at),-1) +FROM typed_ticket_assignment_tracking_history h +WHERE json_extract(h.source_context_json,'$.kind')='assignment'; +INSERT INTO ticket_assignment_delivery_origins +SELECT h.workspace_id,h.ticket_id,h.assignment_id,'assignment-event:' || h.event_id, + (SELECT MAX(e.event_index) FROM typed_ticket_events e + 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 + WHERE e.workspace_id=h.workspace_id AND e.ticket_id=h.ticket_id + AND f.key='orchestrator_assignment_id' AND f.value=h.assignment_id + AND (e.to_state='queued' OR (e.state_field='routing' AND e.reason='queued'))) +FROM typed_ticket_assignment_tracking_history h +WHERE json_extract(h.source_context_json,'$.kind')='migration' + AND EXISTS(SELECT 1 FROM typed_ticket_events e 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 + WHERE e.workspace_id=h.workspace_id AND e.ticket_id=h.ticket_id + AND f.key='orchestrator_assignment_id' AND f.value=h.assignment_id + AND (e.to_state='queued' OR (e.state_field='routing' AND e.reason='queued'))); +"#)?; + Ok(()) +} + +const INPUTS_SQL: &str = r#" +WITH eligible AS ( + SELECT a.*,o.input_event_id,o.after_ticket_event_index + 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 ticket_assignment_delivery_origins o ON o.workspace_id=a.workspace_id AND o.assignment_id=a.assignment_id + JOIN typed_tickets t ON t.workspace_id=a.workspace_id AND t.ticket_id=a.ticket_id + WHERE a.workspace_id=?1 AND a.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 NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r + WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id) +), inputs AS ( + SELECT ticket_id,assignment_id,input_event_id,tracking_json,-1 AS input_order FROM eligible + UNION ALL + SELECT a.ticket_id,a.assignment_id,'ticket-event:' || e.event_index,a.tracking_json,e.event_index + FROM eligible a JOIN typed_ticket_events e ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id + WHERE e.event_index>a.after_ticket_event_index + -- External content/answer inputs, not case/routing/history or own self-report. + -- Ticket supports typed extension events; an answer need not be named comment. + AND e.kind NOT IN ('create','close','state_changed','status_changed','history') + 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') +) +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<TicketAssignmentDelivery> { + 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<Vec<TicketAssignmentDelivery>> { + 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<Vec<TicketAssignmentDelivery>> { + 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<String>>(3)?, + row.get::<_, Option<String>>(4)?, + row.get::<_, Option<String>>(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::<String>(); + 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<TicketAssignmentDelivery> { + 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<String>=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<String> { + 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<String>=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<String>=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(()) +} 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..7a93d3c12 --- /dev/null +++ b/crates/workspace-server/src/assignment_delivery_tests.rs @@ -0,0 +1,794 @@ +// 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_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); + 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:2"); + 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:2", &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() + ); + // Typed extension inputs must not disappear merely because they are not + // comments; own tracking/history events remain excluded. + delivery_store_external_event(&store, 3, "assignment_tracking_updated", None); + delivery_store_external_event(&store, 4, "history", None); + delivery_store_external_event(&store, 5, "owner_answer", None); + 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:5"); + 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(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(path).unwrap(); + barrier.wait(); + store + .reserve_ticket_assignment_delivery("w", "a", &input, &tracking_store_source()) + .unwrap() + }) + }) + .collect::<Vec<_>>(); + let receipts = threads + .into_iter() + .map(|t| t.join().unwrap()) + .collect::<Vec<_>>(); + 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<bool> { + 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(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(&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<_>>(), + 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(path).unwrap(); + barrier.wait(); + delivery_store_start_reserve(&store, "start-a", "a", &tracking_store_source()) + .unwrap() + }) + }) + .collect::<Vec<_>>(); + let admitted = threads + .into_iter() + .map(|t| t.join().unwrap()) + .collect::<Vec<_>>(); + 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(&path).unwrap(); + let fresh = SqliteWorkspaceStore::in_memory().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::<rusqlite::Result<Vec<_>>>()?) + }).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(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(); +} diff --git a/crates/workspace-server/src/assignment_tracking_tests.rs b/crates/workspace-server/src/assignment_tracking_tests.rs index 3fd7f8b6b..7c85c4812 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -439,12 +439,12 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip .iter() .map(|m| m.version) .collect::<Vec<_>>(), - vec![92] + vec![92, 93] ); let store = SqliteWorkspaceStore::open(&path).unwrap(); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 92); + assert_eq!(current_schema_version(conn)?, 93); assert_eq!( conn.query_row( "SELECT applied_at FROM ticket_schema_migrations WHERE version=10", @@ -457,7 +457,8 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip workspace_schema_migration_history(conn)?, vec![ (91, "workspace schema baseline".into()), - (92, "Ticket-owned assignment tracking".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()) { @@ -595,7 +596,7 @@ fn tracking_store_schema91_closed_unknown_assignment_uses_assign_audit_for_first .is_empty() ); store.with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 92); + 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<String>, Option<String>) = 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)?)))?; diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 686be3115..abda3e7b2 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -783,12 +783,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 @@ -811,7 +811,6 @@ impl SqliteWorkspaceAuthority { }) .collect::<Result<Vec<_>>>()?; 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 @@ -827,12 +826,18 @@ impl SqliteWorkspaceAuthority { let action_eligibility = TicketActionEligibility { // Case openness is not readiness. These are explicit operations; // occupancy remains an independent resource constraint. - can_assign_orchestrator: !has_orchestrator && !has_worker, + // 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, - can_start_manual_worker: ticket.meta.workflow_state == TicketState::Open - && !has_worker - && !has_blockers, + // 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, }; @@ -3232,6 +3237,81 @@ mod tests { ); } + #[tokio::test] + async fn unfinished_dependencies_remain_diagnostic_without_disabling_manual_start() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workspace.db"); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + store + .upsert_workspace(&WorkspaceRecord { + workspace_id: "w".into(), + owner_account_id: "owner-account".into(), + display_name: "W".into(), + state: "active".into(), + created_at: "1".into(), + updated_at: "1".into(), + }) + .await + .unwrap(); + let backend = SqliteTicketBackend::open_verified(&path, "w").unwrap(); + let dependent = backend + .create(ticket::NewTicket::new( + "Partial investigation without resources", + )) + .unwrap(); + let prerequisite = backend + .create(ticket::NewTicket::new("Unfinished prerequisite")) + .unwrap(); + backend + .add_ticket_relation( + dependent.id.clone().into(), + ticket::NewTicketRelation { + kind: ticket::TicketRelationKind::DependsOn, + target: prerequisite.id.clone(), + note: Some("Partial work can proceed".into()), + author: Some("owner-account".into()), + }, + ) + .unwrap(); + let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap(); + let detail = authority.ticket(&dependent.id).unwrap(); + assert!(detail.targets.is_empty()); + assert!(detail.action_eligibility.can_start_manual_worker); + assert!(detail.action_eligibility.blockers.is_empty()); + assert_eq!(detail.relations.blockers.len(), 1); + assert_eq!( + detail.relations.blockers[0].blocking_ticket, + prerequisite.id + ); + assert_eq!(detail.relations.outgoing[0].kind, "depends_on"); + let blocked = authority + .query_tickets(TicketQueryRequest { + attention: vec![server_api::TicketAttention::Blocked], + ..TicketQueryRequest::default() + }) + .unwrap(); + assert_eq!(blocked.items.len(), 1); + assert_eq!(blocked.items[0].id, dependent.id); + assert_eq!(blocked.items[0].unresolved_blocker_count, 1); + let prerequisite_detail = authority.ticket(&prerequisite.id).unwrap(); + assert!(prerequisite_detail.assignments.is_empty()); + assert_eq!(prerequisite_detail.state.as_str(), "open"); + rusqlite::Connection::open(&path) + .unwrap() + .execute( + "UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id=?1", + [&dependent.id], + ) + .unwrap(); + assert!( + !authority + .ticket(&dependent.id) + .unwrap() + .action_eligibility + .can_start_manual_worker + ); + } + #[tokio::test] async fn manual_worker_eligibility_accepts_explicit_states_without_reviving_released_work() { let dir = tempfile::tempdir().unwrap(); diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index cb8dfa169..c0fe34c99 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -450,6 +450,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<String>, + pub ticket_id: String, pub operation_id: String, } @@ -7546,6 +7550,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/server.rs b/crates/workspace-server/src/server.rs index 6740bc570..f536b55aa 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1538,6 +1538,7 @@ struct OrchestratorAttentionDelivery { fingerprint: String, request_id: String, content: String, + input: crate::store::TicketAssignmentDelivery, } struct OrchestratorAttentionReservation(Option<Arc<Mutex<OrchestratorAttentionState>>>); @@ -3687,6 +3688,9 @@ impl WorkspaceApi { } } api.recover_backend_jobs()?; + if let Some(orchestrator) = find_online_workspace_orchestrator(&api) { + dispatch_orchestrator_assignment_attention(&api, &orchestrator.worker); + } Ok(api) } @@ -6864,6 +6868,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, } @@ -8483,10 +8488,12 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::CreateWorkspaceWorkerRequest, ) -> std::result::Result<server_api::BrowserCreateWorkerResponse, server_api::RepositoryApiError> { - 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) @@ -8548,11 +8555,12 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::CreateWorkspaceWorkerRequest, ) -> std::result::Result<server_api::BrowserCreateWorkerResponse, server_api::RepositoryApiError> { - 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) @@ -8726,11 +8734,14 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::CreateWorkspaceWorkerRequest, ) -> std::result::Result<server_api::BrowserCreateWorkerResponse, server_api::RepositoryApiError> { - 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) @@ -9192,14 +9203,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<server_api::RuntimeWorkerSpawnResponse, server_api::RepositoryApiError> { - 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)?; @@ -9228,18 +9241,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<server_api::RuntimeWorkerSpawnResponse, server_api::RepositoryApiError> { - 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)?; @@ -13373,6 +13387,7 @@ async fn start_manual_ticket_worker_assignment( record: &TicketRoleAssignmentRecord, operation_id: &str, selected: &[server_api::TicketWorkerAttachmentBinding], + delegation: Option<&TicketStartDelegation>, ) -> ApiResult<TicketRoleAssignmentRecord> { let TicketAssignmentPrincipal::Worker { runtime_id, @@ -13405,6 +13420,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(), @@ -13500,14 +13529,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()); @@ -13531,6 +13561,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( @@ -13884,11 +13916,16 @@ async fn apply_set_ticket_assignment( ) .into()); } + let delegation = ticket_start_delegation( + source.as_ref(), + request.orchestrator_assignment_id.as_deref(), + )?; start_manual_ticket_worker_assignment( &api, &record, &operation_id, &request.workdir_bindings, + delegation.as_ref(), ) .await? } @@ -13900,6 +13937,13 @@ async fn apply_set_ticket_assignment( .into()); } }; + if role == TicketAssignmentRole::Orchestrator + && let Some(orchestrator) = find_workspace_orchestrator(&api) + { + // The Assign row/event/receipt has committed. Delivery failure cannot + // roll back delegation; startup/restore/idle use this same durable scan. + dispatch_orchestrator_assignment_attention(&api, &orchestrator.worker); + } Ok(Json(server_api::TicketRoleAssignmentMutationResponse { workspace_id, ticket_id: ticket.id, @@ -14090,6 +14134,118 @@ 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<Option<TicketStartDelegation>> { + 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<Option<TicketStartDelegation>> { + 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<bool> { + 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, @@ -14102,13 +14258,8 @@ fn validate_ticket_assignment_state( 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(()) } @@ -17233,55 +17384,6 @@ fn active_orchestrator_assignment( } } -fn orchestrator_interested( - api: &WorkspaceApi, - workspace_id: &str, - ticket_id: &str, - _state: &str, -) -> Result<Option<TicketRoleAssignmentRecord>> { - let backend = browser_ticket_backend(api)?; - let ticket = backend.show(ticket_id.into()).map_err(Error::from)?; - if ticket.meta.workflow_state != TicketState::Open { - return Ok(None); - } - let has_unfinished_work = api - .store - .get_active_ticket_worker_assignment(workspace_id, ticket_id)? - .is_some(); - // Transitional routing continues to require an explicit queue request or - // unfinished work. Openness alone is not a routing request. A later case - // decision fences older queue receipts; T-741/742 will replace this path. - let explicitly_queued = ticket - .events - .iter() - .rev() - .find_map(|event| { - if event.attributes.contains_key("queue_root_ticket") { - Some(true) - } else if event.attributes.contains_key("case_action") { - Some(false) - } else { - None - } - }) - .unwrap_or_else(|| { - // Old queued/inprogress cases may predate queue receipts. This frozen - // migration handoff preserves their intent, but any new case decision - // fences it so reopening never revives an old routing request. - ticket.case_history.is_empty() - && ticket - .legacy_workflow_snapshot - .as_ref() - .is_some_and(|snapshot| { - matches!(snapshot.workflow_state.as_str(), "queued" | "inprogress") - }) - }); - if !has_unfinished_work && !explicitly_queued { - return Ok(None); - } - active_orchestrator_assignment(api, workspace_id, ticket_id) -} - fn worker_ticket_source_context( api: &WorkspaceApi, workspace_id: &str, @@ -17440,13 +17542,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(); @@ -18768,24 +18865,19 @@ 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); + dispatch_orchestrator_assignment_attention(api, &orchestrator.worker); } } -fn dispatch_orchestrator_queue_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); +fn dispatch_orchestrator_assignment_attention(api: &WorkspaceApi, worker: &RuntimeWorkerRef) { + with_orchestrator_attention_reservation(api, worker, |state, pending| { + check_orchestrator_assignment_attention(api, worker, state, pending); }); } fn with_orchestrator_attention_reservation( api: &WorkspaceApi, worker: &RuntimeWorkerRef, - mut retry_only: bool, mut check: impl FnMut( &Arc<Mutex<OrchestratorAttentionState>>, Option<OrchestratorAttentionDelivery>, @@ -18795,7 +18887,7 @@ 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 { return; } state.sending = true; @@ -18806,74 +18898,69 @@ 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; + // Accepted Restore is an explicit durable-input recheck even when the + // current check was a no-op. A local cache is never backlog authority. } } -fn check_orchestrator_queue_attention( +fn orchestrator_delivery_target( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, +) -> Result<ticket::TicketAssignmentTrackingSourceContext> { + 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<Mutex<OrchestratorAttentionState>>, pending: Option<OrchestratorAttentionDelivery>, ) { - // 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::Open, - ])) else { - return; - }; - queued.retain(|ticket| { - orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "open") - .ok() - .flatten() - .is_some() - }); - 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::<Vec<_>>() - .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); + } else { + state.lock().unwrap_or_else(|p| p.into_inner()).pending = None; } + return; + } + 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 @@ -18890,27 +18977,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( @@ -18919,30 +19045,76 @@ fn send_orchestrator_attention( state: &Mutex<OrchestratorAttentionState>, 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, @@ -22403,11 +22575,22 @@ fn scoped_worker_control_operation_id(controller: &RuntimeWorkerRef, operation_i ) } +#[cfg(test)] async fn spawn_known_worker( + api: State<WorkspaceApi>, + path: AxumPath<ScopedWorkspacePath>, + headers: HeaderMap, + request: Json<CreateWorkspaceWorkerRequest>, +) -> ApiResult<Json<BrowserCreateWorkerResponse>> { + spawn_known_worker_authenticated(api, path, headers, request, None).await +} + +async fn spawn_known_worker_authenticated( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, headers: HeaderMap, Json(mut request): Json<CreateWorkspaceWorkerRequest>, + context: Option<&server_api::ServerRequestContext>, ) -> ApiResult<Json<BrowserCreateWorkerResponse>> { validate_workspace_scope(&api, &path.workspace_id)?; let source = authenticate_worker_mutation_source(&api, &path.workspace_id, &headers)?; @@ -22445,9 +22628,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( + api.clone(), + headers, + request, + resolved_control_operation, + context, + ) + .await?; if let Err(error) = api .store .create_worker_control_grant(&WorkerControlGrantRecord { @@ -22739,6 +22927,8 @@ async fn scoped_start_workspace_orchestrator( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<BrowserWorkspaceOrchestratorResponse>> { + #[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( @@ -22755,8 +22945,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); @@ -22785,7 +22976,7 @@ async fn scoped_start_workspace_orchestrator( } } 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) { @@ -22865,7 +23056,7 @@ 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))) } @@ -22942,6 +23133,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<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, @@ -25885,6 +26077,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<WorkspaceApi>, AxumPath(path): AxumPath<ScopedRuntimePath>, @@ -27707,6 +27900,7 @@ fn browser_worker_spawn_policy( }, WorkerSpawnAcceptanceRequirement::RunAccepted { expected_segments }, Some(WorkerTicketAssignmentRequest { + orchestrator_assignment_id: assignment.orchestrator_assignment_id, ticket_id, operation_id, }), @@ -27754,6 +27948,7 @@ fn validate_caller_worker_singleton_key(singleton_key: Option<&str>) -> Result<( Ok(()) } +#[cfg(test)] async fn create_workspace_worker( State(api): State<WorkspaceApi>, headers: HeaderMap, @@ -27762,11 +27957,23 @@ 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<WorkerControlOperation>, +) -> ApiResult<Json<BrowserCreateWorkerResponse>> { + 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<WorkerControlOperation>, + context: Option<&server_api::ServerRequestContext>, ) -> ApiResult<Json<BrowserCreateWorkerResponse>> { let claim_lock = request .ticket_assignment @@ -27787,7 +27994,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)? @@ -27920,14 +28143,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 @@ -27955,6 +28179,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, @@ -28491,6 +28721,13 @@ fn resume_internal_worker_restore_intent( request: server_api::WorkerRestoreRequest, ) -> ApiResult<InternalWorkerRestoreResult> { 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) { @@ -29039,12 +29276,40 @@ fn runtime_diagnostics_message(diagnostics: &[RuntimeDiagnostic]) -> String { } } +#[cfg(test)] async fn create_runtime_worker( + api: State<WorkspaceApi>, + runtime_id: AxumPath<String>, + request: Json<server_api::RuntimeWorkerSpawnRequest>, +) -> ApiResult<Json<server_api::RuntimeWorkerSpawnResponse>> { + create_runtime_worker_authenticated(api, runtime_id, request, None).await +} + +async fn create_runtime_worker_authenticated( State(api): State<WorkspaceApi>, AxumPath(runtime_id): AxumPath<String>, Json(request): Json<server_api::RuntimeWorkerSpawnRequest>, + context: Option<&server_api::ServerRequestContext>, ) -> ApiResult<Json<server_api::RuntimeWorkerSpawnResponse>> { 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() @@ -29111,14 +29376,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) => { @@ -29138,6 +29404,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() { @@ -32729,6 +33001,13 @@ impl IntoResponse for ApiError { #[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"); } @@ -37337,6 +37616,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(), }; @@ -37359,6 +37639,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(), }) @@ -37366,6 +37647,7 @@ mod tests { assert!( browser_worker_spawn_policy( Some(CreateWorkspaceWorkerTicketAssignmentRequest { + orchestrator_assignment_id: None, ticket_id: " ".to_string(), operation_id: "operation".to_string(), }), @@ -37432,6 +37714,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(), }), @@ -37532,6 +37815,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(), }), @@ -48017,6 +48301,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(), }, @@ -48047,27 +48332,37 @@ mod tests { } let inputs = execution.take_inputs(); - let expected_states = [ - ("open", "open"), - ("open", "open"), - ("open", "open"), - ("open", "open"), - ]; - assert_eq!(inputs.len(), expected_states.len()); - for ((recipient, content), (previous_state, current_state)) in - inputs.iter().zip(expected_states) - { + assert_eq!(inputs.len(), 4, "Assign input plus three external events"); + let unique = inputs + .iter() + .map(|(_, content)| content.clone()) + .collect::<std::collections::BTreeSet<_>>(); + 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( @@ -48435,6 +48730,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 { @@ -48481,10 +48777,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(), "open", "open") + 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) @@ -48606,6 +48904,7 @@ mod tests { operation_id: &str, ) -> server_api::SetTicketRoleAssignmentRequest { server_api::SetTicketRoleAssignmentRequest { + orchestrator_assignment_id: None, workdir_bindings: fixture .api .store @@ -49014,6 +49313,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 { @@ -49070,6 +49370,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 { @@ -49126,7 +49427,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( @@ -50038,19 +50339,21 @@ mod tests { assert!(!serialized.contains("Ticket notification:")); let notifications = execution.take_inputs(); - assert_eq!(notifications.len(), 1); + assert_eq!( + notifications.len(), + 2, + "initial delegation plus external comment" + ); 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(), - TicketState::Open.as_str(), - TicketState::Open.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 @@ -50091,7 +50394,11 @@ mod tests { .filter(|warning| warning.ticket_id == ticket_ref.id) .cloned() .collect::<Vec<_>>(); - 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" @@ -50105,969 +50412,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<DeterministicExecutionBackend>, - ) { - 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(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); - browser_ticket_backend(&api) - .unwrap() - .queue_ready(ticket.id.into(), "test-request") - .unwrap(); - (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::<Vec<_>>(); - joins - .into_iter() - .map(|j| j.join().unwrap()) - .collect::<Vec<_>>() - }); - 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::<Vec<_>>(); - 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(TicketState::Open); - 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); - browser_ticket_backend(&api) - .unwrap() - .queue_ready(next.id.clone().into(), "test-request") - .unwrap(); - *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<worker_runtime::worker_backend::RuntimeWorkerController, String> - { - 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<worker_runtime::worker_backend::RuntimeWorkerController, String> - { - self.controller(request.worker_ref.worker_id.to_string(), false) - .await - } - async fn restore_controller( - &self, - request: worker_runtime::execution::WorkerExecutionRestoreRequest, - ) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> - { - 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<dyn attention_execution::WorkerExecutionBackend>, - lose_next: std::sync::atomic::AtomicBool, - attempts: Mutex<Vec<String>>, - } - - 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<protocol::Event> { - 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(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); - browser_ticket_backend(&api) - .unwrap() - .queue_ready(ticket.id.clone().into(), "test-request") - .unwrap(); - 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::<Vec<_>>() - }; - 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<F: Fn() -> 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(TicketState::Open); - set_test_ticket_target(&mut input, "test-repository", "HEAD"); - let ticket_ref = backend.create(input).unwrap(); - assign_test_orchestrator(&api, &ticket_ref.id); - browser_ticket_backend(&api) - .unwrap() - .queue_ready(ticket_ref.id.clone().into(), "test-request") - .unwrap(); - - 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::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::<Vec<_>>(); - 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() @@ -51166,6 +50519,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(), }), @@ -51256,6 +50610,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(), }); @@ -51806,6 +51161,36 @@ 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<PathBuf>, ) -> (WorkspaceApi, Arc<DeterministicExecutionBackend>) { 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 ae3340877..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 @@ -448,6 +448,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits .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(), 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 906ca566a..64ca15dc3 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 @@ -1287,23 +1287,33 @@ async fn uncertain_launch_replay_after_target_edit_keeps_claim_and_blocks_compet // Direct policy composition is intentional: this protects routing interest, // independently of the transport/Runtime launch assertions above. #[tokio::test(flavor = "multi_thread")] -async fn orchestrator_interest_requires_queue_receipt_or_unfinished_work_not_just_open_state() { +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!( - orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open") + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) .unwrap() - .is_none() + .iter() + .any(|input| input.ticket_id == queued_id) + == true ); backend .queue_ready(queued_id.clone().into(), "fixture") .unwrap(); assert!( - orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open") + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) .unwrap() - .is_some() + .iter() + .any(|input| input.ticket_id == queued_id) + == true ); fixture.case_action( &queued_id, @@ -1313,9 +1323,14 @@ async fn orchestrator_interest_requires_queue_receipt_or_unfinished_work_not_jus }, ); assert!( - orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "closed") + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) .unwrap() - .is_none() + .iter() + .any(|input| input.ticket_id == queued_id) + == false ); fixture.case_action( &queued_id, @@ -1323,9 +1338,14 @@ async fn orchestrator_interest_requires_queue_receipt_or_unfinished_work_not_jus ticket::TicketCaseAction::Reopen, ); assert!( - orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open") + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) .unwrap() - .is_none() + .iter() + .any(|input| input.ticket_id == queued_id) + == false ); let active_id = fixture.ticket(); @@ -1347,9 +1367,14 @@ async fn orchestrator_interest_requires_queue_receipt_or_unfinished_work_not_jus .all(|event| !event.attributes.contains_key("queue_root_ticket")) ); assert!( - orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &active_id, "open") + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) .unwrap() - .is_some() + .iter() + .any(|input| input.ticket_id == active_id) + == true ); fixture.case_action( &active_id, @@ -1380,8 +1405,13 @@ async fn orchestrator_interest_requires_queue_receipt_or_unfinished_work_not_jus .is_none() ); assert!( - orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &active_id, "open") + fixture + .api + .store + .list_pending_ticket_assignment_deliveries(TEST_WORKSPACE_ID, 100) .unwrap() - .is_none() + .iter() + .any(|input| input.ticket_id == active_id) + == false ); } 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..6da70dc10 --- /dev/null +++ b/crates/workspace-server/src/server_assignment_attention_tests.rs @@ -0,0 +1,1073 @@ +async fn attention_test_api() -> ( + tempfile::TempDir, + WorkspaceApi, + Arc<DeterministicExecutionBackend>, +) { + 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<crate::store::TicketAssignmentDelivery> { + 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::<Vec<_>>(); + joins + .into_iter() + .map(|j| j.join().unwrap()) + .collect::<Vec<_>>() + }); + 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::<Vec<_>>(); + 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_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 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, + |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().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!(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.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. + 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(), 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(); + 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<worker_runtime::worker_backend::RuntimeWorkerController, String> { + 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<worker_runtime::worker_backend::RuntimeWorkerController, String> { + self.controller(request.worker_ref.worker_id.to_string(), false) + .await + } + async fn restore_controller( + &self, + request: worker_runtime::execution::WorkerExecutionRestoreRequest, + ) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> { + 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<dyn attention_execution::WorkerExecutionBackend>, + lose_next: std::sync::atomic::AtomicBool, + attempts: Mutex<Vec<String>>, +} + +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<protocol::Event> { + 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(&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::<Vec<_>>() + }; + 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<F: Fn() -> 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. + browser_ticket_backend(&api) + .unwrap() + .add_event( + ticket.id.clone().into(), + ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, "New owner answer"), + ) + .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::<Vec<_>>(); + 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..b8821a66b --- /dev/null +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -0,0 +1,962 @@ +// 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<Value>, + 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) + ); + 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<DeterministicExecutionBackend>, + 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() +} + +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, "Human answer: choose option B"); + 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() { + 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])); + 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; + notify_ticket_recipients(&api, TEST_WORKSPACE_ID, &ticket.id, "open", "open", None); + delivery_scan(&api, &worker); + assert!( + execution.take_inputs().is_empty(), + "cleared reservation was sent as new work" + ); + assert_eq!( + delivery_assign(&api, &ticket.id, "assign-old").await, + assigned + ); + delivery_scan(&api, &worker); + assert!( + execution.take_inputs().is_empty(), + "old operation retry revived cleared work" + ); + 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); + assert_eq!(execution.take_inputs().len(), 1); + 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" + ); + let old = delivery_tracking(&api, &ticket.id, assignment); + assert!(!old.current); + assert_eq!( + old.tracking.unwrap().status, + ticket::TicketAssignmentTrackingStatus::Waiting, + "clear 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); +} + +// 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<Mutex<Vec<agen::llm_client::Request>>>, +} + +#[async_trait::async_trait] +impl agen::llm_client::LlmClient for DeliveryScriptedClient { + fn clone_boxed(&self) -> Box<dyn agen::llm_client::LlmClient> { + Box::new(self.clone()) + } + async fn stream( + &self, + request: agen::llm_client::Request, + ) -> std::result::Result<agen::llm_client::client::ResponseStream, agen::llm_client::ClientError> + { + 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<worker_runtime::worker_backend::RuntimeWorkerController, String> { + 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<worker_runtime::worker_backend::RuntimeWorkerController, String> { + self.controller(request.worker_ref.worker_id.to_string(), false) + .await + } + async fn restore_controller( + &self, + request: delivery_execution::WorkerExecutionRestoreRequest, + ) -> std::result::Result<worker_runtime::worker_backend::RuntimeWorkerController, String> { + 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<dyn delivery_execution::WorkerExecutionBackend>, + lose_responses: std::sync::atomic::AtomicBool, + attempts: Mutex<Vec<(String, String)>>, +} + +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<protocol::Event> { + self.inner.worker_snapshot(worker) + } +} + +async fn delivery_real_fixture( + root: &Path, + lose_responses: bool, +) -> ( + WorkspaceApi, + Arc<DeliveryResponseLossBackend>, + DeliveryScriptedClient, +) { + let config = test_server_config(root); + let store = Arc::new(SqliteWorkspaceStore::open(&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<LogEntry> { + 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<Value> { + 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<String> { + 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 summary = api.runtime.worker(worker).unwrap(); + if commits == expected_commits + && 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(&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::<Vec<_>>(); + 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(); + browser_ticket_backend(&reopened) + .unwrap() + .add_event( + ticket.id.clone().into(), + ticket::NewTicketEvent::new( + ticket::TicketEventKind::Comment, + "External answer after server restart", + ), + ) + .unwrap(); + 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::<Vec<_>>(); + 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<Body> { + 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<Body>, + 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<String> { + 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::<Vec<_>>(); + 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 index 138fae02b..7ad464549 100644 --- a/crates/workspace-server/src/server_assignment_tracking_tests.rs +++ b/crates/workspace-server/src/server_assignment_tracking_tests.rs @@ -68,7 +68,9 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ })).unwrap(); tracking_response( app.clone(), - request("PUT", &worker_path, worker_input), + 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; @@ -117,15 +119,15 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ .await, saved ); - // Only a genuinely new operation is refused; a receipt never restores - // the cleared current pointer, initializes tracking, or starts work. - tracking_response( + // 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::CONFLICT, + StatusCode::OK, ) .await; - assert_eq!(counts(), before); + assert_ne!(fresh["assignment"]["assignment_id"].as_str(), Some(assignment)); assert_eq!( api.store .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, assignment) @@ -144,16 +146,9 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ .unwrap(), active ); - assert!( - api.store - .get_current_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, + fresh["assignment"]["assignment_id"].as_str().unwrap()); } } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index c88faeec8..a709fa672 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -27,8 +27,11 @@ 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, @@ -37,7 +40,7 @@ pub use ticket::{ }; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 92; +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"; @@ -305,6 +308,11 @@ const MIGRATIONS: &[Migration] = &[ 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)] @@ -1804,6 +1812,52 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { request_fingerprint: &str, created_at: &str, ) -> Result<bool>; + /// Common scan for committed current delegation inputs, not handling status. + fn list_pending_ticket_assignment_deliveries( + &self, + workspace_id: &str, + limit: usize, + ) -> Result<Vec<TicketAssignmentDelivery>>; + /// 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<TicketAssignmentDelivery>; + /// 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<String>; + 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<bool>; + /// 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, @@ -7598,92 +7652,90 @@ impl ControlPlaneStore for SqliteWorkspaceStore { request_fingerprint: &str, created_at: &str, ) -> Result<bool> { - 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<bool> { + 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 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 - ], - )?; + 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<Vec<TicketAssignmentDelivery>> { + 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<TicketAssignmentDelivery> { + 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<String> { + 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, @@ -7693,6 +7745,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. @@ -8036,6 +8089,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)?; } @@ -8091,29 +8145,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "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 = 'open'", - 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 @@ -8335,6 +8367,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result<TicketWorkerAssignmentUpdate> { 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 @@ -11360,6 +11393,119 @@ fn ensure_worker_assignment_available( } 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<bool> { + 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, @@ -11421,6 +11567,7 @@ impl 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(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)?; @@ -11456,24 +11603,13 @@ impl SqliteWorkspaceStore { return Ok(persisted); } - // This is a constraint on a new delegation, not on a saved result. - // Authenticate the source and resolve its full-input receipt first; - // work that began after the original Assign must not block replay. - if record.role == TicketAssignmentRole::Orchestrator { - let active_worker: 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![record.workspace_id, record.ticket_id], - |row| row.get(0), - )?; - if active_worker { - return Err(Error::TicketAssignmentConflict( - "Orchestrator assignment conflicts with an active Worker assignment".into(), - )); - } + // 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)?; } - 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", @@ -11816,13 +11952,15 @@ fn clear_current_ticket_role_assignment_with_source_in_tx( } return Ok(true); } - ensure_no_competing_ticket_claim( - tx, - workspace_id, - ticket_id, - current.principal.worker().as_ref(), - operation_id, - )?; + 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); @@ -13457,8 +13595,22 @@ fn migrate_ticket_tracking_v91_to_v92(conn: &Connection) -> Result<()> { 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}" @@ -13652,6 +13804,37 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { } } } + 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(()) } @@ -14405,6 +14588,7 @@ fn table_columns(conn: &Connection, table_name: &str) -> Result<Vec<String>> { #[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 { @@ -16614,6 +16798,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx 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()}, ] ); @@ -16716,6 +16901,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (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")?); @@ -19313,9 +19499,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .map(|step| step.version) .collect::<Vec<_>>(), if version == 89 { - vec![90, 91, 92] + vec![90, 91, 92, 93] } else { - vec![91, 92] + vec![91, 92, 93] } ); drop(conn); @@ -19330,7 +19516,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx assert_eq!(workspace_schema_migration_history(conn)?.into_iter().filter(|(version, _)| *version >= 90).collect::<Vec<_>>(), 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())]); + (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}"); } @@ -20048,7 +20235,7 @@ 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", "open"); seed_manual_start_ticket(&store, "second", "open"); @@ -20100,7 +20287,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx "2", None ) - .is_err() + .unwrap() ); assert!( store diff --git a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs index 0f1e65591..0ea9c64e7 100644 --- a/crates/workspace-server/src/ticket_tag_upgrade_tests.rs +++ b/crates/workspace-server/src/ticket_tag_upgrade_tests.rs @@ -265,7 +265,7 @@ fn assert_retained_classification_upgrade(workspace_version: i64, component: i64 drop(conn); let store = SqliteWorkspaceStore::open(&path).unwrap(); store.with_conn(|conn| { - assert_eq!(current_schema_version(conn)?,92); + 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) { @@ -493,7 +493,7 @@ fn workspace90_cases9_add_classification_without_reinterpreting_reasoned_history let store = SqliteWorkspaceStore::open(&path).unwrap(); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 92); + assert_eq!(current_schema_version(conn)?,93); assert_eq!( conn.query_row( "SELECT MAX(version) FROM ticket_schema_migrations", diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index f81403e7e..e241f8aee 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", @@ -479,6 +484,11 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { ("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"), diff --git a/docs/development/ticket-assignment-delivery.md b/docs/development/ticket-assignment-delivery.md new file mode 100644 index 000000000..9945531e1 --- /dev/null +++ b/docs/development/ticket-assignment-delivery.md @@ -0,0 +1,60 @@ +# Assign-driven Orchestrator delivery and start admission (T-742) + +## Separate authorities + +[Ticket assignment tracking](ticket-assignment-tracking.md) remains the sole delegation/handling authority (Ticket component schema 11). Workspace schema 93 adds **operational** input origins, delivery reservations, exact rendered requests, acceptance acknowledgements, and assignment/source-bound start fences. These tables do not create another assignment identity or handling status. + +Assign commits the current pointer, initial waiting, assignment event and source-bound operation receipt together. Its committed tracking-history insertion records an initial input origin in that same transaction. Delivery runs only after commit. Runtime acceptance, running/idle/stop, MR changes, and delivery acknowledgement never update `waiting`/`inprogress`/`done`. + +A delivery acknowledgement means the Runtime accepted the immutable Notify request. It is not an assertion that the model considered the input, satisfied the Ticket, or started execution. Runtime notification/activation receipts provide the corresponding durable session boundary. Self-reported progress/result remains a separate explicit mutation. + +## Common input scan and delivery + +Mutation notification, Backend startup, Orchestrator create (including already-online), accepted restore, replacement, and running→idle all use `list_pending_ticket_assignment_deliveries`. There is no parallel Queue/open-state routing predicate. Openness is a new-work safety boundary, not delegation intent. + +The scan selects current `workspace_agent:workspace-orchestrator` assignments with actionable handling and an explicit committed input origin. It excludes released identities, unknown legacy relationships, done/history and closed cases. External content/answer events after the origin are new inputs; handling self-reports and case/routing history are not. Waiting alone is never a resend reason. Close releases operational work; reopen alone does not revive the old delegation. Explicit reissue creates a new assignment and initial input. + +Schema 92→93 preserves original assignment/event/Queue evidence. Native Assign history establishes its input origin; migrated tracking contributes an input only where its already-reconciled assignment-specific Queue fence is present. Ambiguous/historical rows remain diagnostic data, not fresh requests. Earlier migrations and Ticket schema 11 are unchanged. + +Delivery order: + +1. Resolve the currently registered online Worker and active Runtime binding from Backend authority (a stopped Worker is not automatically restored). +2. Reserve the exact `assignment_id`/`input_event_id` under IMMEDIATE, rechecking current delegation, singleton and binding. +3. Render using the reservation's metadata, including waiting reason and next action. Persist the **entire rendered content**, not just its metadata or a title fingerprint. +4. Send the saved content with the saved `request_id`, outside the transaction. Response loss retains the same request/payload. Concurrent reservations converge on the same identity, and Runtime request receipts deduplicate actual acceptance. +5. Acknowledge accepted delivery under another IMMEDIATE transaction after rechecking current authority. The acknowledgement never changes handling. + +Process-local attention locks prevent concurrent/reentrant send loops; they are not backlog authority. Losing that cache does not lose a delegation or resend acknowledged input. Replacement does not create assignment rows; only pending, unacknowledged inputs are recovered. Accepted history is not replayed into new work merely because a Worker was exchanged. + +## Public start/action handoff for T-743 + +Existing canonical tracking query/history/update endpoints, Tools/WIP, Ticket query/detail, and retained assignment projections remain available; see the tracking document. Model-visible notices include the committed delegation/input identity and bounded waiting/next-action context. The Server attention template is updated here; final Web/TUI/all-Prompt operation alignment belongs to T-743. + +Start request contracts now carry optional `orchestrator_assignment_id`: + +- `CreateWorkspaceWorkerRequest.ticket_assignment` (including Worker control spawn). +- `RuntimeWorkerSpawnRequest.ticket_assignment`. +- `SetTicketRoleAssignmentRequest` for an existing Worker. +- `SpawnTicketWorker` and generic `WorkerSpawn` tool input/lifecycle forwarding. + +An authenticated current Orchestrator **must** supply its observed delegation identity. Backend checks it with the actual `ServerRequestContext.runtime_source.authentication_binding_id`; it never derives the source from model role text or reacquires another binding as authentication proof. Missing/stale/cleared/reissued identities and obsolete Workers cannot admit work. Manual/generic authorized starts omit the delegation field and do not implicitly assign the Orchestrator. New starts and existing Worker assignments share the same reservation/start fence. + +The fenced reservation extends the existing Ticket operation receipt and Worker allocation/recovery identity. Reserve/retry, bind and finalization validate the saved assignment/source under IMMEDIATE. Recovery/pre-dispatch checks use the same fence. Clear/reassign can invalidate a reservation but do not stop, transfer or clean up already-running work. Unknown external outcomes keep their reservation/resource fences for explicit reconciliation rather than permitting a second start. Runtime I/O is never inside a database transaction. + +Unfinished dependencies remain query/relation diagnostics and do not disable manual/Orchestrator new starts or existing Worker assignment. They are never implicitly started. Case closure, authenticated permissions, actual unfinished work/claims, resource/grant conflicts, target containment and recovery remain independent guards. Repository/Workdir-free work, read-only work and caller-selected published Profiles are preserved. + +Action projection permits delegation independently of existing Worker occupancy; actual Worker start eligibility still excludes unfinished Worker work and closed cases. `can_start_manual_worker` no longer includes an unresolved-dependency predicate. `can_assign_orchestrator` permits an explicit fresh Assign/reissue operation even when a current row exists; callers can supply the observed expected assignment for CAS. `can_unassign_orchestrator` uses the retained current pointer, not active-work or case-state filtering. These authoring actions are not Worker transfer. Final browser controls must use these Backend decisions rather than recreating old phase/dependency gates. + +Delivery diagnostics are available through the typed Store scan/reservation/ack records and structured `ticket_assignment_delivery_pending` warning (`assignment_id`, `input_event_id`, `delivery_id`, recipient and error category, never content). Existing `ticket_notification_delivery_failed` remains for recipient failures. No browser acknowledgement/status-edit endpoint is introduced: successful delivery is not a user handling update. T-743 can consume the operational query independently of handling without inventing a third status in tracking. + +## DB caller/transaction handoff for T-748 and protocol boundaries + +Store callers are in `store.rs`; operational SQL/mutations are in `assignment_delivery.rs`. The public Store methods are `list_pending_ticket_assignment_deliveries`, `reserve_ticket_assignment_delivery`, `prepare_ticket_assignment_delivery_content`, `ack_ticket_assignment_delivery`, `reserve_ticket_assignment_operation_for_orchestrator`, and `validate_ticket_assignment_operation_fence`. Existing bind/finalization writes call the saved-operation guard inside their own IMMEDIATE transaction. + +Operational tables: `ticket_assignment_delivery_origins`, `ticket_assignment_deliveries`, `ticket_assignment_delivery_requests`, `ticket_assignment_delivery_acknowledgements`, `ticket_assignment_operation_fences`. Workspace explicit purge inventory includes their scoped rows and temporarily suspends/restores their immutable-delete guards in the existing guarded purge transaction. Ordinary operations cannot delete or rewrite receipts. + +T-741 set/clear/update/history remain unchanged in transaction ownership: actual source binding/singleton checks, receipt comparison, current/event CAS, audit and result must stay together. A future DB blocking adapter must move the whole synchronous transaction as one unit, not split its queries around awaits. Server Runtime method admission and `ServerRuntimeMutationSourceContext.authentication_binding_id` are not changed. T-739 close/reopen and T-715 identity-specific work releases remain execution boundaries. + +## Validation limits + +Local Store tests, authenticated HTTP tests, real embedded Controller/session tests, and loopback remote Runtime HTTP tests prove persistence, concurrency, source/assignment fencing, payload replay, resource contracts and activation dedupe. They do not prove model judgment quality or final Web/TUI/all-Prompt behavior (T-743/T-744). No live DB migration, environment update, target integration, Ticket conclusion or Runtime Worker/Workdir cleanup is part of this handoff. diff --git a/openapi/server-api.json b/openapi/server-api.json index 78819a744..a8b846fa9 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -1606,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" } @@ -8953,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" } @@ -9304,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" }, @@ -16813,7 +16834,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774" + "x-yoi-source-digest": "sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b" }, "openapi": "3.1.0", "paths": { 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/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index 88c4da3bb..d40e0565a 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:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) +// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) // 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 a3f8fc78e..307f65277 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:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) +// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) // 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 7a78b1505..516e54f07 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:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) +// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) // 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 60e817c7a..7c9829198 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) +// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) // 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 @@ -83,7 +83,7 @@ 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>; }; diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 8db0ab85e..3868137c5 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:982cc7edc8fd8655a14e943b9d0022df56e941107def02251186d8ddebfd6774) +// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) // 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; }; From 56795fb92cb9def9bcd701bd51d92e99526f8eff Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 05:39:33 +0900 Subject: [PATCH 16/39] fix: drain concurrent assignment inputs and discard stale pending retries --- crates/workspace-server/src/server.rs | 47 ++-- .../src/server_assignment_attention_tests.rs | 23 +- .../src/server_assignment_delivery_tests.rs | 210 +++++++++++++----- .../development/ticket-assignment-delivery.md | 2 +- 4 files changed, 199 insertions(+), 83 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index f536b55aa..5e5742bb5 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -1528,7 +1528,7 @@ impl workdir::WorkdirSession for ExternalProviderWorkdirSession { #[derive(Default)] struct OrchestratorAttentionState { sending: bool, - restore_recheck: bool, + input_recheck: bool, accepted: Option<OrchestratorAttentionDelivery>, pending: Option<OrchestratorAttentionDelivery>, } @@ -1547,10 +1547,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) } } @@ -18865,12 +18866,22 @@ fn maybe_dispatch_orchestrator_turn_end( if orchestrator.worker.runtime_id == EMBEDDED_WORKER_RUNTIME_ID && orchestrator.worker.worker_id == worker_id { - dispatch_orchestrator_assignment_attention(api, &orchestrator.worker); + // 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_assignment_attention(api: &WorkspaceApi, worker: &RuntimeWorkerRef) { - with_orchestrator_attention_reservation(api, worker, |state, pending| { + with_orchestrator_attention_reservation(api, worker, true, |state, pending| { check_orchestrator_assignment_attention(api, worker, state, pending); }); } @@ -18878,6 +18889,7 @@ fn dispatch_orchestrator_assignment_attention(api: &WorkspaceApi, worker: &Runti fn with_orchestrator_attention_reservation( api: &WorkspaceApi, worker: &RuntimeWorkerRef, + defer_input_check_if_reserved: bool, mut check: impl FnMut( &Arc<Mutex<OrchestratorAttentionState>>, Option<OrchestratorAttentionDelivery>, @@ -18888,6 +18900,9 @@ fn with_orchestrator_attention_reservation( let pending = { let mut state = state.lock().unwrap_or_else(|p| p.into_inner()); 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; @@ -18898,8 +18913,8 @@ fn with_orchestrator_attention_reservation( if !reservation.complete() { return; } - // Accepted Restore is an explicit durable-input recheck even when the - // current check was a no-op. A local cache is never backlog authority. + // 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. } } @@ -18951,10 +18966,19 @@ fn check_orchestrator_assignment_attention( .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; } - return; + // 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 @@ -22969,11 +22993,6 @@ 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_assignment_attention(&api, &existing.worker); diff --git a/crates/workspace-server/src/server_assignment_attention_tests.rs b/crates/workspace-server/src/server_assignment_attention_tests.rs index 6da70dc10..d72a8e735 100644 --- a/crates/workspace-server/src/server_assignment_attention_tests.rs +++ b/crates/workspace-server/src/server_assignment_attention_tests.rs @@ -154,7 +154,7 @@ async fn orchestrator_attention_barrier_callers_share_unsent_reservation() { } #[tokio::test] -async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_comparison() { +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; @@ -187,6 +187,7 @@ async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_ with_orchestrator_attention_reservation( &api_for_caller, &worker_for_caller, + true, |state, pending| { if checks == 0 && pause_before_comparison { entered_tx.send(()).unwrap(); @@ -224,7 +225,7 @@ async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_ 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 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(); @@ -239,7 +240,7 @@ async fn orchestrator_attention_restore_rechecks_reserved_noop_before_and_after_ let state = state.lock().unwrap(); assert!(state.accepted.is_none()); assert!(!state.sending); - assert!(!state.restore_recheck); + assert!(!state.input_recheck); } } @@ -308,19 +309,19 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() { "existing" ); let retry = execution.take_inputs(); - assert_eq!(retry.len(), 1); + assert_eq!(retry.len(), 2, "acknowledged retry must also drain the changed backlog"); assert_eq!(retry[0].1, pending.content); - assert_eq!( - execution.take_input_request_ids(), - vec![Some(pending.request_id)] - ); + 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); - let changed = execution.take_inputs(); - assert_eq!(changed.len(), 1); - assert!(changed[0].1.contains("Changed backlog")); + assert!(execution.take_inputs().is_empty()); } #[tokio::test] diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs index b8821a66b..489674c19 100644 --- a/crates/workspace-server/src/server_assignment_delivery_tests.rs +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -36,7 +36,11 @@ async fn delivery_http( "{method} {path}: {}", String::from_utf8_lossy(&bytes) ); - serde_json::from_slice(&bytes).unwrap() + if bytes.is_empty() { + Value::Null + } else { + serde_json::from_slice(&bytes).unwrap() + } } async fn delivery_assign(api: &WorkspaceApi, ticket: &str, key: &str) -> Value { @@ -330,65 +334,157 @@ async fn assignment_done_historical_inputs_do_not_restart_work_but_reissue_is_ne #[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_http( + &api, + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/thread-events", ticket.id), + Some(serde_json::to_value(NewTicketEvent::new( + TicketEventKind::Comment, "Human answer: go ahead", + )).unwrap()), + StatusCode::NO_CONTENT, + )); + } 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, "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])); - 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; - notify_ticket_recipients(&api, TEST_WORKSPACE_ID, &ticket.id, "open", "open", None); - delivery_scan(&api, &worker); - assert!( - execution.take_inputs().is_empty(), - "cleared reservation was sent as new work" - ); - assert_eq!( - delivery_assign(&api, &ticket.id, "assign-old").await, - assigned - ); - delivery_scan(&api, &worker); - assert!( - execution.take_inputs().is_empty(), - "old operation retry revived cleared work" - ); - 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); - assert_eq!(execution.take_inputs().len(), 1); + 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(), 1); - assert_ne!( - ids[0], old_ids[0], - "new assignment reused stale input identity" - ); - let old = delivery_tracking(&api, &ticket.id, assignment); - assert!(!old.current); - assert_eq!( - old.tracking.unwrap().status, - ticket::TicketAssignmentTrackingStatus::Waiting, - "clear 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" - ); + 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); } diff --git a/docs/development/ticket-assignment-delivery.md b/docs/development/ticket-assignment-delivery.md index 9945531e1..59fa87214 100644 --- a/docs/development/ticket-assignment-delivery.md +++ b/docs/development/ticket-assignment-delivery.md @@ -24,7 +24,7 @@ Delivery order: 4. Send the saved content with the saved `request_id`, outside the transaction. Response loss retains the same request/payload. Concurrent reservations converge on the same identity, and Runtime request receipts deduplicate actual acceptance. 5. Acknowledge accepted delivery under another IMMEDIATE transaction after rechecking current authority. The acknowledgement never changes handling. -Process-local attention locks prevent concurrent/reentrant send loops; they are not backlog authority. Losing that cache does not lose a delegation or resend acknowledged input. Replacement does not create assignment rows; only pending, unacknowledged inputs are recovered. Accepted history is not replayed into new work merely because a Worker was exchanged. +Process-local attention locks prevent concurrent/reentrant send loops; they are not backlog authority. A committed-input/startup/Restore trigger arriving during a reserved scan is coalesced into an input recheck and drained on reservation release, including when that scan read an empty snapshot. Discarding or acknowledging a pending retry continues the common scan in the same reservation; a successful retry cannot consume the trigger for newer input. Reentrant idle observations caused by a send do not schedule another retry of a failed send. These liveness repairs change no DB transaction or public API boundary. Losing that cache does not lose a delegation or resend acknowledged input. Replacement does not create assignment rows; only pending, unacknowledged inputs are recovered. Accepted history is not replayed into new work merely because a Worker was exchanged. ## Public start/action handoff for T-743 From b1076f1d6530e8ee7d9023408417107f1f141113 Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 06:43:16 +0900 Subject: [PATCH 17/39] feat(backend): bound synchronous database and host execution ownership --- crates/workdir/src/http.rs | 161 +- crates/workspace-server/src/auth.rs | 202 +- crates/workspace-server/src/db_executor.rs | 957 ++ .../src/frozen_content_state_tests.rs | 2 + .../src/frozen_repository_key_tests.rs | 4 + .../src/frozen_runtime_binding_tests.rs | 2 + crates/workspace-server/src/hosts.rs | 145 +- crates/workspace-server/src/lib.rs | 7 + .../src/runtime_subscription.rs | 13 +- crates/workspace-server/src/server.rs | 7916 ++++++++++------- .../src/server/subjektiv_job.rs | 146 +- .../src/server/tests/auth_logging_tests.rs | 2 +- .../tests/protocol_backpressure_tests.rs | 5 +- .../server/tests/workspace_admission_tests.rs | 58 +- .../src/server/worker_operations.rs | 349 +- .../src/server/workspace_admission.rs | 22 + .../src/server_db_execution_tests.rs | 606 ++ crates/workspace-server/src/server_drive.rs | 13 +- .../src/server_drive_grants.rs | 73 +- .../src/server_workspace_config.rs | 431 +- .../src/server_workspace_config_tests.rs | 4 +- crates/workspace-server/src/store.rs | 679 +- .../workspace-server/src/worker_projection.rs | 455 +- crates/workspace-server/src/worker_source.rs | 42 +- .../src/worker_source/runtime_request.rs | 410 +- .../src/workspace_drive_host.rs | 73 +- .../src/workspace_subscription.rs | 221 +- docs/development/backend-db-execution.md | 95 + 28 files changed, 9192 insertions(+), 3901 deletions(-) create mode 100644 crates/workspace-server/src/db_executor.rs create mode 100644 crates/workspace-server/src/server_db_execution_tests.rs create mode 100644 docs/development/backend-db-execution.md diff --git a/crates/workdir/src/http.rs b/crates/workdir/src/http.rs index 940506ca7..ae155b008 100644 --- a/crates/workdir/src/http.rs +++ b/crates/workdir/src/http.rs @@ -432,16 +432,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<dyn std::future::Future<Output = Result<String, WorkdirError>> + 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<String, WorkdirError>; + method: String, + path_and_query: String, + body: Vec<u8>, + ) -> WorkdirHttpAuthorizationFuture; } struct FixedBearerToken(Arc<str>); @@ -455,11 +462,12 @@ mod client { impl WorkdirHttpAuthorization for FixedBearerToken { fn bearer_token( &self, - _method: &str, - _path_and_query: &str, - _body: &[u8], - ) -> Result<String, WorkdirError> { - Ok(self.0.to_string()) + _method: String, + _path_and_query: String, + _body: Vec<u8>, + ) -> WorkdirHttpAuthorizationFuture { + let token = self.0.clone(); + Box::pin(async move { Ok(token.to_string()) }) } } @@ -509,7 +517,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) @@ -560,7 +570,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) @@ -864,7 +877,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) @@ -918,11 +934,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<Mutex<Vec<(String, String, Vec<u8>)>>>, + } + + impl WorkdirHttpAuthorization for RejectAuthorization { + fn bearer_token( + &self, + method: String, + path_and_query: String, + body: Vec<u8>, + ) -> 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/workspace-server/src/auth.rs b/crates/workspace-server/src/auth.rs index aa202f2de..bfe4ab4c3 100644 --- a/crates/workspace-server/src/auth.rs +++ b/crates/workspace-server/src/auth.rs @@ -89,12 +89,30 @@ pub fn parse_cookie(headers: &HeaderMap, cookie_name: &str) -> Option<String> { }) } -pub async fn resolve_request_actor<S: ControlPlaneStore + ?Sized>( - store: &S, +pub async fn resolve_request_actor( + store: std::sync::Arc<dyn ControlPlaneStore>, headers: &HeaderMap, cookie_name: &str, ) -> Result<Option<RequestActor>> { - 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<String>, + session: Option<String>, +) -> Result<Option<RequestActor>> { + 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 @@ -109,7 +127,7 @@ pub async fn resolve_request_actor<S: ControlPlaneStore + ?Sized>( } } - 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); @@ -173,6 +191,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<SqliteWorkspaceStore> { + 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() + .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/db_executor.rs b/crates/workspace-server/src/db_executor.rs new file mode 100644 index 000000000..1c44ef8c9 --- /dev/null +++ b/crates/workspace-server/src/db_executor.rs @@ -0,0 +1,957 @@ +//! 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. +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<dyn Send>; +} + +tokio::task_local! { + static OPERATION_LIFETIME: Arc<dyn DbOperationLifetime>; +} + +pub(crate) async fn with_operation_lifetime<F: Future>( + lifetime: Arc<dyn DbOperationLifetime>, + future: F, +) -> F::Output { + OPERATION_LIFETIME.scope(lifetime, future).await +} + +#[derive(Default)] +struct Accepted { + last_job_id: u64, + unfinished: BTreeSet<u64>, +} + +#[derive(Default)] +struct Tracking { + accepted: Mutex<Accepted>, + finished: Notify, +} + +struct Completion { + tracking: Arc<Tracking>, + 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<dyn FnOnce() + Send>, + // Field drop order releases authority before completion becomes observable. + _lifetime: Option<Box<dyn Send>>, + lifetime_source: Option<Arc<dyn DbOperationLifetime>>, + _completion: Completion, + runtime: Option<tokio::runtime::Handle>, + 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<mpsc::SyncSender<Job>>, + tracking: Arc<Tracking>, + max_jobs: usize, +} + +impl DbExecutor { + pub fn new(limits: DbExecutorLimits) -> Result<Self> { + 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::<Job>(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<DbExecutor> = 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<T, E>( + &self, + operation: impl FnOnce() -> std::result::Result<T, E> + Send + 'static, + ) -> std::result::Result<T, E> + where + T: Send + 'static, + E: From<Error> + 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<T, E>( + &self, + operation: impl FnOnce() -> std::result::Result<T, E> + Send + 'static, + ) -> std::result::Result<oneshot::Receiver<std::result::Result<T, E>>, E> + where + T: Send + 'static, + E: From<Error> + 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<oneshot::Sender<()>>, + 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<BusyGate>, + } + + 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::<BusyGate>() }; + 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<T>(future: impl Future<Output = T>) -> 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<std::sync::atomic::AtomicUsize>); + struct Retained(Arc<std::sync::atomic::AtomicUsize>); + impl DbOperationLifetime for Lease { + fn retain(&self) -> Box<dyn Send> { + 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<dyn DbOperationLifetime> = 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<dyn DbOperationLifetime> = 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<dyn DbOperationLifetime> = 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<std::sync::atomic::AtomicUsize>, + dropped: Option<oneshot::Sender<(bool, usize)>>, + } + + 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<dyn DbOperationLifetime> = 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<String>, + phase: Option<String>, + accepted_job_id: Option<u64>, + } + + 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<Mutex<Vec<UnknownDiagnostic>>>); + + impl<S: tracing::Subscriber> tracing_subscriber::Layer<S> 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<std::sync::atomic::AtomicUsize>, + started: Option<oneshot::Sender<(bool, usize)>>, + 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<dyn DbOperationLifetime> = 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..a23c29b04 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( diff --git a/crates/workspace-server/src/hosts.rs b/crates/workspace-server/src/hosts.rs index cb8dfa169..73733162b 100644 --- a/crates/workspace-server/src/hosts.rs +++ b/crates/workspace-server/src/hosts.rs @@ -23,7 +23,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::{ @@ -3473,6 +3476,38 @@ impl WorkspaceRuntimeAuthorization { } } + /// Offload the complete live-binding check and request signing operation. + /// Claims are minted inside the bounded job, not cached before admission. + pub(crate) async fn issue_async( + &self, + method: String, + path_and_query: String, + operation: String, + worker_id: Option<String>, + body: Vec<u8>, + ) -> Result<String, RuntimeDiagnostic> { + 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, @@ -3645,25 +3680,24 @@ impl std::fmt::Debug for RemoteWorkdirAuthorization { impl WorkdirHttpAuthorization for RemoteWorkdirAuthorization { fn bearer_token( &self, - method: &str, - path_and_query: &str, - body: &[u8], - ) -> Result<String, WorkdirError> { - 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<u8>, + ) -> 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(), - ) + }) }) } } @@ -6344,6 +6378,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() + .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 { diff --git a/crates/workspace-server/src/lib.rs b/crates/workspace-server/src/lib.rs index fefe24d92..eb113b079 100644 --- a/crates/workspace-server/src/lib.rs +++ b/crates/workspace-server/src/lib.rs @@ -10,6 +10,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; @@ -159,6 +160,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/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<Option<String>, 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/server.rs b/crates/workspace-server/src/server.rs index 6740bc570..f6535f9c8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -885,7 +885,7 @@ impl ExternalProviderWorkdirSession { } } - fn audit_operation( + async fn audit_operation( &self, operation_id: &ExternalWorkdirOperationId, operation_kind: &str, @@ -894,25 +894,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}" @@ -960,7 +969,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(), @@ -977,7 +987,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(), )); @@ -1007,7 +1018,8 @@ impl ExternalProviderWorkdirSession { } else { "failed" }, - )?; + ) + .await?; result } @@ -1851,6 +1863,27 @@ impl WorkerRemovalService { target_runtime_id: &str, target_worker_id: &str, reason: &str, + ) -> std::result::Result<worker::WorkspaceResponse, String> { + 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(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<worker::WorkspaceResponse, String> { let reason = reason.trim(); if reason.is_empty() || reason.len() > 512 { @@ -1922,11 +1955,36 @@ 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<worker::WorkspaceResponse, String> { + 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(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, @@ -2220,6 +2278,16 @@ impl WorkspaceServerApi { } } + // 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<T: Send + 'static>( + &self, + operation: impl FnOnce(Arc<dyn ControlPlaneStore>) -> Result<T> + Send + 'static, + ) -> Result<T> { + let store = self.store.clone(); + self.store.db_executor().run(move || operation(store)).await + } + async fn admission_for_workspace(&self, workspace_id: &str) -> Arc<WorkspaceAdmission> { workspace_admission_gate(&self.admission_gates, workspace_id).await } @@ -2232,7 +2300,13 @@ 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)?; @@ -2249,34 +2323,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(()) @@ -2287,16 +2373,20 @@ impl WorkspaceServerApi { actor_account_id: &str, workspace_id: &str, ) -> Result<WorkspaceDeletionPreflightResponse> { + 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?; + host_io_executor()?.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" => {} @@ -2319,23 +2409,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<WorkspaceDeletionOperationResponse> { - 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? @@ -2344,10 +2441,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) @@ -2359,13 +2459,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, @@ -2377,22 +2481,34 @@ 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 host_io_executor()? + .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) @@ -2428,29 +2544,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 @@ -2497,15 +2624,28 @@ impl WorkspaceServerApi { async fn shutdown_feature_storage(&self) -> Result<()> { let apis = self.apis.lock().await.values().cloned().collect::<Vec<_>>(); for api in apis { - api.feature_storage.shutdown().map_err(|error| { - Error::Store(format!("failed to shut down Feature storage: {error}")) - })?; + 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?; } 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? @@ -2575,7 +2715,7 @@ async fn resolve_server_actor( headers: &HeaderMap, ) -> std::result::Result<Option<RequestActor>, 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 { @@ -2614,6 +2754,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); @@ -2625,7 +2770,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(), @@ -2640,8 +2785,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" + }, ) } @@ -2716,7 +2865,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, @@ -2881,41 +3030,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(), @@ -2938,20 +3084,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 } @@ -3002,7 +3146,7 @@ fn workspace_request_is_callback(method: &Method, path: &str, workspace_id: &str async fn dispatch_workspace_request( State(api): State<WorkspaceServerApi>, - mut request: Request, + request: Request, ) -> Response { let path = request.uri().path().to_owned(); let workspace_id = scoped_workspace_id(&path); @@ -3028,6 +3172,23 @@ 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/") @@ -3044,7 +3205,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), }; @@ -3474,8 +3641,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(), @@ -3490,20 +3667,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<dyn WorkspaceSigningMaterialStore> = 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() @@ -3546,11 +3737,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(), @@ -3561,15 +3760,24 @@ 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 { @@ -3582,10 +3790,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), }), @@ -4080,77 +4285,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 = backend_job_cleanup_executor().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( @@ -4285,7 +4457,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}"), @@ -4351,7 +4523,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}"), @@ -4478,6 +4650,7 @@ impl WorkspaceApi { let attempt_id = attempt_id.to_string(); handle.spawn(async move { tokio::time::sleep(delay).await; + let admission = host_io_executor().and_then(|executor| executor.dispatch(move || { let Ok(Some(attempt)) = api.store .get_backend_job_attempt(&api.config.workspace_id, &job_id, &attempt_id) @@ -4501,6 +4674,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"); + } }); } @@ -6218,7 +6395,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, ) @@ -7171,7 +7348,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 { @@ -7202,7 +7384,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 { @@ -7235,7 +7422,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) } @@ -7265,7 +7456,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, @@ -7280,9 +7476,12 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<WorkspaceCatalogListResponse, server_api::RepositoryApiError> { 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())) @@ -7295,12 +7494,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(), @@ -7321,9 +7525,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 @@ -7368,10 +7575,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 @@ -7387,9 +7598,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(); @@ -7429,17 +7649,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( @@ -7474,13 +7696,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( @@ -9364,7 +9593,7 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::WorkerRestoreResponse, 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?; restore_runtime_worker_with_context(api, runtime_id, worker_id, operation_context, request) .await .map(|Json(response)| response) @@ -9382,7 +9611,7 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::WorkerRestoreResponse, 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)?; scoped_restore_runtime_worker_with_context( @@ -9409,10 +9638,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::WorkerRetentionResponse, 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)?; - 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) @@ -9429,10 +9659,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::WorkerRetentionResponse, 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)?; - 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) @@ -9469,7 +9700,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) @@ -9486,8 +9717,9 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::RuntimeWorkerInputResult, server_api::RepositoryApiError> { 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())?; @@ -9508,10 +9740,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::RuntimeWorkerInputResult, 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)?; - 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())?; @@ -9671,8 +9904,9 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::RuntimeWorkerLifecycleResult, server_api::RepositoryApiError> { 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( @@ -9694,10 +9928,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::RuntimeWorkerLifecycleResult, 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)?; - 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( @@ -9718,8 +9953,9 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::RuntimeWorkerLifecycleResult, server_api::RepositoryApiError> { 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( @@ -9741,10 +9977,11 @@ impl server_api::ServerApi for ServerApiContractService { ) -> std::result::Result<server_api::RuntimeWorkerLifecycleResult, 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)?; - 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( @@ -10559,23 +10796,28 @@ impl server_api::ServerApi for ServerApiContractService { query: server_api::TicketAssignmentTrackingQuery, ) -> std::result::Result<server_api::TicketAssignmentTrackingList, server_api::RepositoryApiError> { - let api = self.workspace_api()?; - 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::<Vec<_>>()) - } else { - api.authority.ticket_assignment_tracking(&ticket_id) - } - .map_err(ApiError::from) - .map_err(ApiError::into_repository_api_error)?; - Ok(server_api::TicketAssignmentTrackingList(values)) + 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::<Vec<_>>()) + } 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( @@ -11402,21 +11644,28 @@ 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 = host_io_executor() + .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) } @@ -11429,20 +11678,29 @@ 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 = host_io_executor() + .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( @@ -12144,8 +12402,13 @@ async fn scoped_list_flows( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<Vec<FlowSourceRecord>>> { - 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( @@ -12153,28 +12416,34 @@ async fn scoped_put_flow( AxumPath(path): AxumPath<ScopedWorkspacePath>, Json(request): Json<server_api::PutFlowRequest>, ) -> ApiResult<Json<FlowSourceRecord>> { - 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( @@ -12182,65 +12451,79 @@ async fn scoped_resolve_flow_source( AxumPath(path): AxumPath<ScopedWorkspacePath>, Json(request): Json<FlowSourceResolveRequest>, ) -> ApiResult<Json<ResolvedFlowSource>> { - 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<WorkspaceApi>, AxumPath(path): AxumPath<ScopedFlowPath>, ) -> ApiResult<Json<FlowSourceRecord>> { - 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( @@ -12311,8 +12594,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( @@ -12327,10 +12615,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( @@ -12408,12 +12701,19 @@ async fn scoped_get_workspace_config_history( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<WorkspaceConfigHistoryPath>, ) -> ApiResult<Json<server_api::ConfigTreeSnapshot>> { - 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)] @@ -12426,34 +12726,44 @@ async fn scoped_get_prompt_projection( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<worker::WorkspacePromptProjection>> { - 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<WorkspaceApi>, AxumPath(workspace_id): AxumPath<String>, ) -> ApiResult<Json<server_api::WorkspaceMemorySettings>> { - 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( @@ -12461,19 +12771,24 @@ async fn scoped_update_workspace_memory_settings( AxumPath(workspace_id): AxumPath<String>, Json(request): Json<server_api::UpdateWorkspaceMemorySettingsRequest>, ) -> ApiResult<Json<server_api::WorkspaceMemorySettings>> { - 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( @@ -12984,41 +13299,51 @@ async fn scoped_get_workspace_config_tree( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<server_api::WorkspaceConfigTreeResponse>> { - 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<WorkspaceApi>, AxumPath(path): AxumPath<WorkspaceConfigEntryPath>, ) -> ApiResult<Json<server_api::ConfigEntry>> { - 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( @@ -13026,13 +13351,18 @@ async fn scoped_commit_workspace_config_tree( AxumPath(path): AxumPath<ScopedWorkspacePath>, Json(request): Json<server_api::ConfigCommitRequest>, ) -> ApiResult<(StatusCode, Json<server_api::WorkspaceConfigTreeResponse>)> { - 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.store + .db_executor() + .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. @@ -13096,20 +13426,25 @@ async fn scoped_get_profile_settings( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<ProfileSettingsResponse>> { - 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( @@ -13125,8 +13460,13 @@ async fn scoped_get_ticket( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedRecordPath>, ) -> ApiResult<Json<TicketDetail>> { - validate_workspace_scope(&api, &path.workspace_id)?; - browser_ticket_detail(&api, &path.id) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + browser_ticket_detail(&api, &path.id) + }) + .await } async fn scoped_query_tickets( @@ -13134,8 +13474,13 @@ async fn scoped_query_tickets( AxumPath(path): AxumPath<ScopedWorkspacePath>, Json(query): Json<TicketQueryRequest>, ) -> ApiResult<Json<TicketQueryResponse>> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(api.authority.query_tickets(query)?)) + api.store + .db_executor() + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(api.authority.query_tickets(query)?)) + }) + .await } async fn scoped_show_ticket( @@ -13143,11 +13488,16 @@ async fn scoped_show_ticket( AxumPath(path): AxumPath<ScopedRecordPath>, Json(query): Json<TicketShowRequest>, ) -> ApiResult<Json<TicketDetail>> { - 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.store + .db_executor() + .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<TicketAssignmentRole> { @@ -13223,19 +13573,24 @@ async fn scoped_list_ticket_assignments( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedRecordPath>, ) -> ApiResult<Json<server_api::TicketRoleAssignmentsResponse>> { - 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.store + .db_executor() + .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( @@ -13824,7 +14179,11 @@ async fn scoped_set_ticket_assignment_with_context( request: Json<server_api::SetTicketRoleAssignmentRequest>, ) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { validate_workspace_scope(&api, &path.0.0)?; - let (author, source) = authenticated_assignment_source(&api, &headers, context)?; + let source_api = api.clone(); + let context = context.clone(); + let (author, source) = host_io_executor()? + .run(move || authenticated_assignment_source(&source_api, &headers, &context)) + .await?; apply_set_ticket_assignment(State(api), path, request, author, Some(source)).await } @@ -13835,46 +14194,75 @@ async fn apply_set_ticket_assignment( assigned_by: String, source: Option<crate::store::TicketOrchestratorAssignmentSourceContext>, ) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { - 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, - }; + let prep_api = api.clone(); + let prep_workspace = workspace_id.clone(); + let (ticket, role, operation_id, expected_assignment_id, record, workdir_bindings) = api + .store + .db_executor() + .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 = match role { TicketAssignmentRole::Orchestrator => { - if let Some(source) = source.as_ref() { - api.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. - 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 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; + 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(), + ) + } + }) + .await? } TicketAssignmentRole::Worker => { if expected_assignment_id.is_some() { @@ -13884,13 +14272,8 @@ async fn apply_set_ticket_assignment( ) .into()); } - start_manual_ticket_worker_assignment( - &api, - &record, - &operation_id, - &request.workdir_bindings, - ) - .await? + start_manual_ticket_worker_assignment(&api, &record, &operation_id, &workdir_bindings) + .await? } TicketAssignmentRole::Owner | TicketAssignmentRole::Contributor => { return Err(Error::TicketAssignmentConflict( @@ -13924,7 +14307,11 @@ async fn scoped_clear_ticket_assignment_with_context( query: Query<server_api::ClearTicketRoleAssignmentQuery>, ) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { validate_workspace_scope(&api, &path.0.0)?; - let (author, source) = authenticated_assignment_source(&api, &headers, context)?; + let source_api = api.clone(); + let context = context.clone(); + let (author, source) = host_io_executor()? + .run(move || authenticated_assignment_source(&source_api, &headers, &context)) + .await?; apply_clear_ticket_assignment(State(api), path, query, author, Some(source)).await } @@ -13935,65 +14322,70 @@ async fn apply_clear_ticket_assignment( author: String, source: Option<crate::store::TicketOrchestratorAssignmentSourceContext>, ) -> ApiResult<Json<server_api::TicketRoleAssignmentMutationResponse>> { - 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, - })) + api.store + .db_executor() + .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( @@ -14405,49 +14797,54 @@ async fn scoped_edit_ticket_item( AxumPath(path): AxumPath<ScopedRecordPath>, Json(request): Json<server_api::BrowserEditTicketRequest>, ) -> ApiResult<Json<TicketDetail>> { - 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.store + .db_executor() + .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) + 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( @@ -14455,21 +14852,26 @@ async fn scoped_append_ticket_event( AxumPath(path): AxumPath<ScopedRecordPath>, Json(request): Json<server_api::BrowserAppendTicketEventRequest>, ) -> ApiResult<Json<TicketDetail>> { - 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.store + .db_executor() + .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( @@ -14648,17 +15050,24 @@ 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 = host_io_executor().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( @@ -14694,6 +15103,13 @@ async fn execute_ticket_rest_operation_with_context( .await?; return Ok(TicketBackendOperationResult::Ticket(updated)); } + let job_api = api.clone(); + let workspace_id = workspace_id.to_owned(); + let context = context.cloned(); + host_io_executor()?.run(move || { + let api = &job_api; + let workspace_id = workspace_id.as_str(); + let context = context.as_ref(); validate_ticket_operation_repository_keys(api, workspace_id, &operation)?; let mut backend = SqliteTicketBackend::open_verified( api.config.database_path.clone(), @@ -14891,6 +15307,7 @@ async fn execute_ticket_rest_operation_with_context( } } Ok(result) + }).await } #[cfg(test)] @@ -15853,6 +16270,8 @@ async fn scoped_list_merge_requests( AxumPath(workspace_id): AxumPath<String>, Query(query): Query<server_api::MergeRequestListQuery>, ) -> ApiResult<Json<MergeRequestListResponse>> { + host_io_executor()?.run(move || { + let workspace_id = parse_workspace_id(&workspace_id)?; require_workspace_access(&workspace_id, &api)?; let ticket_id = query @@ -15961,6 +16380,8 @@ async fn scoped_list_merge_requests( items, next_cursor: page.next_cursor, })) + + }).await } fn merge_ref_diagnostic(error: ApiError) -> MergeRequestRefDiagnostic { @@ -16066,159 +16487,167 @@ async fn scoped_show_merge_request( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Query(query): Query<server_api::MergeRequestThreadQuery>, ) -> ApiResult<Json<server_api::MergeRequestDetailResponse>> { - 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, + host_io_executor()? + .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::<Result<Vec<_>>>()?; + let merge_request = public_merge_request(&api, &workspace_id, mr)?; + Ok(Json(server_api::MergeRequestDetailResponse { + merge_request, + source, + target, + linked_tickets, + })) }) - .collect::<Result<Vec<_>>>()?; - 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<WorkspaceApi>, AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, ) -> ApiResult<Json<server_api::MergeRequestReadinessResponse>> { - 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())) + host_io_executor()? + .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( @@ -16227,71 +16656,78 @@ async fn scoped_open_merge_request( AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json<server_api::OpenMergeRequestRequest>, ) -> ApiResult<Json<server_api::PublicMergeRequest>> { - 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, - )?)) + host_io_executor()? + .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( @@ -16299,19 +16735,23 @@ async fn scoped_merge_request_thread( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Query(query): Query<server_api::MergeRequestThreadQuery>, ) -> ApiResult<Json<Vec<server_api::MergeRequestThreadEvent>>> { - 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(), - )) + host_io_executor()? + .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( @@ -16366,20 +16806,24 @@ async fn scoped_register_reviewer_child_session( AxumPath(workspace_id): AxumPath<String>, Json(input): Json<server_api::RegisterReviewerChildSessionRequest>, ) -> ApiResult<StatusCode> { - 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) + host_io_executor()? + .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( @@ -16388,107 +16832,114 @@ async fn scoped_register_merge_request_review_capability( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json<server_api::RegisterMergeRequestReviewCapabilityRequest>, ) -> ApiResult<StatusCode> { - 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 - )) + host_io_executor()? + .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( @@ -16496,67 +16947,73 @@ async fn scoped_submit_merge_request_review( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json<server_api::SubmitMergeRequestReviewRequest>, ) -> ApiResult<Json<server_api::ReviewEvent>> { - 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))) + host_io_executor()? + .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( @@ -16565,44 +17022,50 @@ async fn scoped_revoke_merge_request_review( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json<server_api::RevokeMergeRequestReviewRequest>, ) -> ApiResult<Json<server_api::ReviewRevokedEvent>> { - 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))) + host_io_executor()? + .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( @@ -16611,94 +17074,100 @@ async fn scoped_complete_merge_request( AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, Json(input): Json<server_api::CompleteMergeRequestRequest>, ) -> ApiResult<Json<server_api::MergeEvent>> { - 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) + host_io_executor()? + .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. @@ -16721,47 +17190,56 @@ async fn scoped_ticket_assignment_tracking_update_with_context( AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(update): Json<ticket::TicketAssignmentTrackingUpdate>, ) -> ApiResult<Json<ticket::TicketAssignmentTrackingProjection>> { - 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( + let context = context.clone(); + api.store + .db_executor() + .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, - 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)) + })?; + let source_context = ticket::TicketAssignmentTrackingSourceContext { + actor: format!("worker:{}/{}", source.runtime_id, source.worker_id), + runtime_id: source.runtime_id, + worker_id: source.worker_id, + 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( @@ -16769,25 +17247,31 @@ async fn scoped_ticket_assignment_tracking_history( AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Query(query): Query<server_api::TicketAssignmentTrackingHistoryQuery>, ) -> ApiResult<Json<server_api::TicketAssignmentTrackingHistoryPage>> { - 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, - })) + 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( @@ -16856,6 +17340,10 @@ async fn apply_ticket_case_update( AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(mut request): Json<ticket::TicketCaseUpdate>, ) -> ApiResult<Json<ticket::Ticket>> { + let actor = actor.cloned(); + api.store.db_executor().run(move || { + let actor = actor.as_ref(); + 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)?; @@ -16912,6 +17400,8 @@ async fn apply_ticket_case_update( .update_case(&ticket_id, request) .map_err(Error::from)?; Ok(Json(ticket)) + + }).await } fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { @@ -17533,44 +18023,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<T, E>( + api: &WorkspaceApi, + session_guard: &Arc<tokio::sync::OwnedMutexGuard<()>>, + operation: impl FnOnce(WorkspaceApi) -> std::result::Result<T, E> + Send + 'static, +) -> std::result::Result<T, E> +where + T: Send + 'static, + E: From<Error> + 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<tokio::sync::OwnedMutexGuard<()>>, ) -> Result<WorkdirSessionHandle> { - 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) @@ -17578,9 +18117,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" { @@ -17647,31 +18190,62 @@ 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(); + host_io_executor()? + .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 @@ -17800,10 +18374,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<Vec<LogicalWorkdirAttachment>> { let attachments = api .store .list_worker_workdir_links(&api.config.workspace_id, worker)? @@ -17846,6 +18420,14 @@ fn sync_runtime_worker_workdir_attachments( }) }) .collect::<Result<Vec<_>>>()?; + Ok(attachments) +} + +fn replace_runtime_worker_workdir_attachments( + api: &WorkspaceApi, + worker: &RuntimeWorkerRef, + attachments: Vec<LogicalWorkdirAttachment>, +) -> ApiResult<()> { let result = api .runtime .replace_worker_workdir_attachments(worker, attachments) @@ -17865,12 +18447,67 @@ 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<tokio::sync::OwnedMutexGuard<()>>, +) -> 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(); + host_io_executor()? + .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<tokio::sync::OwnedMutexGuard<()>>, ) -> 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<Vec<WorkerWorkdirLinkRecord>> { + Ok(api .store .list_worker_workdir_links(&api.config.workspace_id, worker)? .into_iter() @@ -17884,11 +18521,7 @@ async fn refresh_current_worker_session_locked( !matches!(record.source, WorkdirRegistrySource::WorkspaceConfig { .. }) }) }) - .collect::<Vec<_>>(); - if remaining.len() == 1 { - open_current_worker_workdir_session_locked(api, worker, &remaining[0]).await?; - } - Ok(()) + .collect()) } #[derive(Debug, Deserialize)] @@ -17903,35 +18536,104 @@ async fn scoped_submit_backend_job_result( headers: HeaderMap, Json(request): Json<SubmitBackendJobResultRequest>, ) -> ApiResult<Json<BackendJobResultAcceptance>> { - 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( + host_io_executor()? + .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<tokio::sync::OwnedMutexGuard<()>>, +) -> Result<Option<WorkerWorkdirLinkRecord>> { + 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<tokio::sync::OwnedMutexGuard<()>>>, +) -> 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 = host_io_executor()? + .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( @@ -17940,115 +18642,139 @@ async fn scoped_attach_current_worker_workdir( headers: HeaderMap, Json(request): Json<AttachCurrentWorkerWorkdirRequest>, ) -> ApiResult<Json<server_api::CurrentWorkerWorkdirAttachmentResponse>> { - validate_workspace_scope(&api, &path.workspace_id)?; - let worker = current_worker_identity(&api, &path.workspace_id, &headers)?; + let stage_api = api.clone(); + let worker = host_io_executor()? + .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(), @@ -18059,7 +18785,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(), @@ -18068,56 +18794,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 { @@ -18247,7 +18963,10 @@ 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 = host_io_executor()? + .run(move || current_worker_identity(&stage_api, &workspace_id, &headers)) + .await?; detach_current_worker_workdir( &api, &worker, @@ -18258,6 +18977,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<tokio::sync::OwnedMutexGuard<()>>, +) -> 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, @@ -18265,70 +18996,84 @@ async fn detach_current_worker_workdir( expected_connection_id: Option<&str>, ) -> ApiResult<server_api::CurrentWorkerWorkdirAttachmentResponse> { 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::<Vec<_>>(); + .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 { @@ -18340,41 +19085,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<tokio::sync::OwnedMutexGuard<()>>>, ) -> ApiResult<WorkerWorkdirLinkRecord> { - let find = || -> Result<Option<WorkerWorkdirLinkRecord>> { - 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 @@ -18483,19 +19223,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 = host_io_executor()?.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) @@ -18527,7 +19272,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 @@ -18536,7 +19281,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) @@ -18546,7 +19291,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 @@ -18567,12 +19312,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 @@ -18587,7 +19334,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, @@ -18595,7 +19342,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 @@ -19024,15 +19771,20 @@ async fn scoped_get_memory_document( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<MemoryDocumentResponse>> { - 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( @@ -19055,13 +19807,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( @@ -19069,13 +19826,20 @@ async fn scoped_list_memory_staging( AxumPath(path): AxumPath<ScopedWorkspacePath>, Query(query): Query<server_api::MemoryStagingQuery>, ) -> ApiResult<Json<MemoryStagingListResponse>> { - 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( @@ -19084,21 +19848,26 @@ async fn scoped_memory_backend_operation( _context: server_api::ServerRequestContext, Json(operation): Json<MemoryBackendOperation>, ) -> ApiResult<Json<MemoryBackendHttpResponse>> { - 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; @@ -19106,6 +19875,62 @@ const SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX: &str = crate::hosts::SUBJEKTIV_CONSOLIDATION_SINGLETON_PREFIX; const SUBJEKTIV_MEMORY_CONSOLIDATION_PROFILE: &str = "subjektiv-memory-consolidation"; +// Runtime callbacks may themselves need DB authority. Synchronous Host workflows +// therefore have a separate finite lane: never monopolize the sole DB worker while +// waiting for Runtime I/O. Existing transaction helpers still finish before I/O. +// Teardown waits must not occupy the ordinary Host lane: a Job may need a +// user stop/control operation on that lane before its Run can finish. Cleanup +// therefore has its own finite owner, still independent of the DB callback lane. +fn backend_job_cleanup_executor() -> Result<crate::db_executor::DbExecutor> { + static CLEANUP: std::sync::OnceLock<Option<crate::db_executor::DbExecutor>> = + std::sync::OnceLock::new(); + CLEANUP + .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_io_executor() -> Result<crate::db_executor::DbExecutor> { + static HOST: std::sync::OnceLock<Option<crate::db_executor::DbExecutor>> = + std::sync::OnceLock::new(); + HOST.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) +} + +// Some existing lifecycle workflows interleave owned async resource locks with +// synchronous DB/Runtime APIs. Poll the complete workflow on the finite Host +// lane using the original runtime (never create a replacement runtime for its +// cached services). Cancellation releases neither locks nor accepted ownership. +// No SQL transaction contains an await/Runtime call, and this lane is separate +// from the DB worker needed by Runtime callbacks. Do not nest this helper. +async fn run_host_workflow<T, E>( + future: impl std::future::Future<Output = std::result::Result<T, E>> + Send + 'static, +) -> std::result::Result<T, E> +where + T: Send + 'static, + E: From<Error> + Send + 'static, +{ + let runtime = + tokio::runtime::Handle::try_current().map_err(|_| E::from(Error::DbExecutorUnavailable))?; + host_io_executor() + .map_err(E::from)? + .run(move || runtime.block_on(future)) + .await +} + fn open_subjektiv_store(api: &WorkspaceApi) -> ApiResult<crate::subjektiv::SubjektivStore> { crate::subjektiv::SubjektivStore::open(&api.feature_storage, &api.subjektiv_registration) .map_err(|error| ApiError::from(Error::Store(error.to_string()))) @@ -19323,18 +20148,23 @@ async fn scoped_create_subjektiv_subject( AxumPath(path): AxumPath<ScopedWorkspacePath>, Json(request): Json<server_api::SubjektivSubjectCreateRequest>, ) -> ApiResult<(StatusCode, Json<server_api::SubjektivSubjectResponse>)> { - 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.store + .db_executor() + .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)] @@ -19350,76 +20180,86 @@ async fn scoped_list_subjektiv_subjects( AxumPath(path): AxumPath<ScopedWorkspacePath>, Query(query): Query<server_api::SubjektivSubjectListQuery>, ) -> ApiResult<Json<server_api::SubjektivSubjectListResponse>> { - 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::<SubjektivSubjectCursor>("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.store + .db_executor() + .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::<SubjektivSubjectCursor>("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::<ApiResult<Vec<_>>>()?, - 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::<ApiResult<Vec<_>>>()?, + next_cursor, + has_more: page.has_more, + })) + }) + .await } async fn scoped_get_subjektiv_subject( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>, ) -> ApiResult<Json<server_api::SubjektivSubjectResponse>> { - 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.store + .db_executor() + .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( @@ -19427,17 +20267,22 @@ async fn scoped_update_subjektiv_subject_behavior( AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>, Json(request): Json<server_api::SubjektivSubjectBehaviorUpdateRequest>, ) -> ApiResult<Json<server_api::SubjektivSubjectResponse>> { - 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.store + .db_executor() + .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( @@ -19458,47 +20303,53 @@ async fn scoped_get_subjektiv_resident_surface( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>, ) -> ApiResult<Json<server_api::SubjektivResidentSurfaceResponse>> { - 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( @@ -19506,14 +20357,19 @@ async fn scoped_list_subjektiv_memories( AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>, Query(query): Query<server_api::SubjektivMemoryQueryRequest>, ) -> ApiResult<Json<server_api::SubjektivMemoryQueryResponse>> { - 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( @@ -19521,21 +20377,26 @@ async fn scoped_get_subjektiv_memory( AxumPath(path): AxumPath<ScopedSubjektivMemoryPath>, Query(query): Query<server_api::SubjektivMemoryDetailQuery>, ) -> ApiResult<Json<server_api::SubjektivMemoryReadResponse>> { - 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( @@ -19543,31 +20404,41 @@ async fn scoped_list_subjektiv_memory_changes( AxumPath(path): AxumPath<ScopedSubjektivMemoryPath>, Query(query): Query<server_api::SubjektivMemoryChangesQuery>, ) -> ApiResult<Json<server_api::SubjektivMemoryListChangesResponse>> { - 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<WorkspaceApi>, AxumPath(path): AxumPath<ScopedSubjektivSubjectPath>, ) -> ApiResult<Json<server_api::SubjektivSubjectResponse>> { - 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.store + .db_executor() + .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( @@ -19576,58 +20447,63 @@ async fn scoped_stage_subjektiv_candidate( context: server_api::ServerRequestContext, Json(mut request): Json<server_api::SubjektivStageCandidateRequest>, ) -> ApiResult<Json<server_api::SubjektivStageCandidateResponse>> { - 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.store + .db_executor() + .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( @@ -19636,25 +20512,34 @@ async fn scoped_record_subjektiv_session( context: server_api::ServerRequestContext, Json(request): Json<server_api::SubjektivRecordSessionRequest>, ) -> ApiResult<Json<server_api::SubjektivRecordSessionResponse>> { - 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, - })) + 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; @@ -19748,50 +20633,61 @@ async fn scoped_subjektiv_session_backend( context: server_api::ServerRequestContext, Json(request): Json<server_api::SubjektivSessionBackendRequest>, ) -> ApiResult<Json<server_api::SubjektivSessionBackendResponse>> { - 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.store + .db_executor() + .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( @@ -20982,115 +21878,121 @@ async fn scoped_subjektiv_memory_backend( context: server_api::ServerRequestContext, Json(request): Json<server_api::SubjektivMemoryBackendRequest>, ) -> ApiResult<Json<server_api::SubjektivMemoryBackendResponse>> { - 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. - let (expected, label) = match &request.operation { - Op::ResidentSummary(_) => ( - Some(SubjektivWorkerAuthority::Subject), - "resident surface read", - ), - Op::ResidentContext(_) => ( - Some(SubjektivWorkerAuthority::Subject), - "resident subject context read", - ), - Op::ValidateProposal(_) => ( - Some(SubjektivWorkerAuthority::Subject), - "Memory proposal validation", - ), - Op::ReceiptStatus(_) => ( - Some(SubjektivWorkerAuthority::Subject), - "explicit candidate receipt lookup", - ), - Op::StageExplicit(_) => ( - Some(SubjektivWorkerAuthority::Subject), - "explicit candidate staging", - ), - Op::ListCandidates(_) => ( - Some(SubjektivWorkerAuthority::Consolidation), - "candidate listing", - ), - Op::ReadCandidate(_) => ( - Some(SubjektivWorkerAuthority::Consolidation), - "candidate read", - ), - Op::DecideCandidate(_) => ( - Some(SubjektivWorkerAuthority::Consolidation), - "candidate decision", - ), - Op::PrepareSurface(_) => ( - Some(SubjektivWorkerAuthority::Consolidation), - "surface generation preparation", - ), - Op::PublishSurface(_) => ( - Some(SubjektivWorkerAuthority::Consolidation), - "surface publication", - ), - Op::FailSurface(_) => ( - Some(SubjektivWorkerAuthority::Consolidation), - "surface generation failure", - ), - 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 + api.store + .db_executor() + .run(move || { + 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. + let (expected, label) = match &request.operation { + Op::ResidentSummary(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "resident surface read", + ), + Op::ResidentContext(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "resident subject context read", + ), + Op::ValidateProposal(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "Memory proposal validation", + ), + Op::ReceiptStatus(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "explicit candidate receipt lookup", + ), + Op::StageExplicit(_) => ( + Some(SubjektivWorkerAuthority::Subject), + "explicit candidate staging", + ), + Op::ListCandidates(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "candidate listing", + ), + Op::ReadCandidate(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "candidate read", + ), + Op::DecideCandidate(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "candidate decision", + ), + Op::PrepareSurface(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "surface generation preparation", + ), + Op::PublishSurface(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "surface publication", + ), + Op::FailSurface(_) => ( + Some(SubjektivWorkerAuthority::Consolidation), + "surface generation failure", + ), + Op::Query(_) | Op::Read(_) | Op::ListChanges(_) => (None, "recall"), }; - 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, - )?, - _ => {} + if let Some(expected) = expected { + require_subjektiv_worker_authority(authority, expected, label)?; } - 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( + 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, - &subject_id, - grant.map(|g| g.candidate_ids), - job, - )? - } - }; - Ok(Json(subjektiv::api::execute( - &store, - &host_context, - request.operation, - )?)) + &host_context, + request.operation, + )?)) + }) + .await } fn subjektiv_store_error(error: crate::subjektiv::SubjektivError) -> Error { @@ -21194,10 +22096,17 @@ async fn start_subjektiv_staging_consolidation( subject_id: &str, operation: MemoryConsolidateStagingOperation, ) -> ApiResult<MemoryConsolidationOutput> { - 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(), @@ -21205,10 +22114,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, @@ -21235,7 +22152,10 @@ async fn start_subjektiv_staging_consolidation( }); } - subjektiv_job::dispatch(&api, subject_id, candidate_count, total_bytes) + let subject = subject_id.to_owned(); + host_io_executor()? + .run(move || subjektiv_job::dispatch(&api, &subject, candidate_count, total_bytes)) + .await } async fn scoped_memory_consolidation( @@ -21243,8 +22163,12 @@ async fn scoped_memory_consolidation( AxumPath(path): AxumPath<ScopedWorkspacePath>, Json(operation): Json<MemoryConsolidateStagingOperation>, ) -> ApiResult<Json<MemoryConsolidationOutput>> { - validate_workspace_scope(&api, &path.workspace_id)?; - Ok(Json(start_memory_staging_consolidation(api, operation)?)) + host_io_executor()? + .run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; + Ok(Json(start_memory_staging_consolidation(api, operation)?)) + }) + .await } fn start_memory_staging_consolidation( @@ -21444,72 +22368,92 @@ async fn scoped_list_skills( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> { - 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<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<worker::skill::SkillCatalogResponse>> { - 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<WorkspaceApi>, AxumPath(path): AxumPath<ScopedSkillPath>, ) -> ApiResult<Json<worker::skill::SkillDetailResponse>> { - 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<WorkspaceApi>, AxumPath(path): AxumPath<ScopedSkillPath>, ) -> ApiResult<Json<worker::skill::SkillActivationResponse>> { - 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 { @@ -21559,8 +22503,13 @@ async fn scoped_query_objectives( AxumPath(path): AxumPath<ScopedWorkspacePath>, Json(query): Json<ObjectiveQueryRequest>, ) -> ApiResult<Json<ObjectiveQueryResponse>> { - 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( @@ -21568,10 +22517,15 @@ async fn scoped_show_objective( AxumPath(path): AxumPath<ScopedObjectivePath>, Json(query): Json<ObjectiveShowRequest>, ) -> ApiResult<Json<ObjectiveDetail>> { - 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( @@ -21579,15 +22533,20 @@ async fn scoped_create_objective( AxumPath(path): AxumPath<ScopedWorkspacePath>, Json(request): Json<ObjectiveCreateRequest>, ) -> ApiResult<Json<ObjectiveDetail>> { - 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( @@ -21595,16 +22554,21 @@ async fn scoped_edit_objective( AxumPath(path): AxumPath<ScopedObjectivePath>, Json(request): Json<ObjectiveEditRequest>, ) -> ApiResult<Json<ObjectiveDetail>> { - 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( @@ -21612,11 +22576,16 @@ async fn scoped_set_objective_state( AxumPath(path): AxumPath<ScopedObjectivePath>, Json(request): Json<ObjectiveStateRequest>, ) -> ApiResult<Json<ObjectiveDetail>> { - 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( @@ -21624,22 +22593,32 @@ async fn scoped_link_objective_ticket( AxumPath(path): AxumPath<ScopedObjectivePath>, Json(request): Json<ObjectiveLinkTicketRequest>, ) -> ApiResult<Json<ObjectiveDetail>> { - 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<WorkspaceApi>, AxumPath(path): AxumPath<ScopedObjectiveTicketPath>, ) -> ApiResult<Json<ObjectiveDetail>> { - 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( @@ -22147,6 +23126,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 } @@ -22161,7 +23143,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(), @@ -22217,22 +23200,27 @@ async fn scoped_get_workspace_worker( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspaceWorkerReferencePath>, ) -> ApiResult<Json<server_api::WorkerSummary>> { - 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 + host_io_executor()? + .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( @@ -22355,8 +23343,13 @@ async fn scoped_workspace_orchestrator_status( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<BrowserWorkspaceOrchestratorResponse>> { - 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( @@ -22364,36 +23357,42 @@ async fn list_known_workers( AxumPath(path): AxumPath<ScopedWorkspacePath>, headers: HeaderMap, ) -> ApiResult<Json<server_api::WorkerControlListResponse>> { - 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, - })) + host_io_executor()? + .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 { @@ -22528,7 +23527,9 @@ async fn send_known_worker_input( 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?; @@ -22564,7 +23565,9 @@ async fn cancel_known_worker( 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)) @@ -22583,7 +23586,9 @@ async fn stop_known_worker( 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)) @@ -22617,41 +23622,45 @@ async fn scoped_list_worker_observation_sessions( AxumPath(path): AxumPath<ScopedWorkspacePath>, headers: HeaderMap, ) -> ApiResult<Json<server_api::WorkerObservationSessionsResponse>> { - 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") + host_io_executor()? + .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::<Vec<_>>(); + 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::<Vec<_>>(); - Ok(Json(server_api::WorkerObservationSessionsResponse { - sessions, - })) + .await } async fn scoped_capture_worker_observation_session( @@ -22739,6 +23748,8 @@ async fn scoped_start_workspace_orchestrator( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<BrowserWorkspaceOrchestratorResponse>> { + host_io_executor()?.run(move || { + validate_workspace_scope(&api, &path.workspace_id)?; let _guard = api.orchestrator_spawn_lock.lock().map_err(|_| { ApiError::with_diagnostics( @@ -22867,6 +23878,8 @@ async fn scoped_start_workspace_orchestrator( drop(_guard); dispatch_orchestrator_queue_attention(&api, &worker.worker, false); Ok(Json(workspace_orchestrator_response(&api, disposition))) + + }).await } fn worker_launch_worker_summary(worker: InternalWorkerSummary) -> WorkerLaunchWorkerSummary { @@ -22998,13 +24011,17 @@ async fn scoped_list_runtime_working_directories( ) -> ApiResult<Json<BrowserWorkingDirectoryListResponse>> { 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), - })) + host_io_executor()? + .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( @@ -23028,7 +24045,11 @@ async fn scoped_runtime_working_directory_detail( ) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> { 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) + host_io_executor()? + .run(move || { + working_directory_detail_for_runtime(api, &path.runtime_id, &path.working_directory_id) + }) + .await } fn workdir_removal_source_actor( @@ -23114,19 +24135,24 @@ async fn cleanup_runtime_working_directory( ) -> ApiResult<WorkingDirectoryRemovalResponse> { 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) + host_io_executor()? + .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( @@ -23178,9 +24204,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; @@ -23245,6 +24280,18 @@ async fn stop_external_workdir_provider( async fn fence_external_workdir_grant_expired( api: &WorkspaceApi, grant_id: &str, +) -> ApiResult<ExternalWorkdirGrantRecord> { + 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<ExternalWorkdirGrantRecord> { loop { let mut grant = api @@ -23314,6 +24361,8 @@ async fn scoped_create_external_workdir_grant( Extension(actor): Extension<RequestActor>, Json(request): Json<ExternalWorkdirGrantCreateRequest>, ) -> ApiResult<(StatusCode, Json<ExternalWorkdirGrantResponse>)> { + 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() @@ -23383,6 +24432,8 @@ async fn scoped_create_external_workdir_grant( StatusCode::CREATED, Json(external_workdir_grant_response(&grant)?), )) + + }).await } fn external_workdir_grant_response( @@ -23407,16 +24458,21 @@ async fn scoped_external_workdir_provider_ws( AxumPath((workspace_id, grant_id)): AxumPath<(String, String)>, ws: WebSocketUpgrade, ) -> ApiResult<Response> { - 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?; @@ -23563,81 +24619,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, @@ -23645,19 +24722,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, @@ -23807,55 +24901,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) = @@ -23875,17 +24995,27 @@ async fn scoped_get_external_workdir_grant( Extension(actor): Extension<RequestActor>, AxumPath((workspace_id, grant_id)): AxumPath<(String, String)>, ) -> ApiResult<Json<ExternalWorkdirGrantResponse>> { - 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 { @@ -23912,44 +25042,57 @@ async fn scoped_revoke_external_workdir_grant( Extension(actor): Extension<RequestActor>, AxumPath((workspace_id, grant_id)): AxumPath<(String, String)>, ) -> ApiResult<Json<ExternalWorkdirGrantResponse>> { - 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; @@ -23974,70 +25117,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<tokio::sync::OwnedMutexGuard<()>>)>, ) -> 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)] @@ -24098,17 +25259,28 @@ async fn release_external_workdir_attachment_locked( api: &WorkspaceApi, workdir_id: &str, link: &WorkerWorkdirLinkRecord, + session_guard: &Arc<tokio::sync::OwnedMutexGuard<()>>, ) -> 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 { @@ -24123,9 +25295,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(()) } @@ -24133,23 +25306,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<tokio::sync::OwnedMutexGuard<()>>)>, ) -> 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(()) } @@ -24158,14 +25342,19 @@ async fn scoped_list_working_directories( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkspacePath>, ) -> ApiResult<Json<BrowserWorkingDirectoryListResponse>> { - 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.store + .db_executor() + .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( @@ -24180,40 +25369,46 @@ async fn scoped_working_directory_detail( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedWorkingDirectoryPath>, ) -> ApiResult<Json<BrowserWorkingDirectoryDetailResponse>> { - 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(), - })) - } + host_io_executor()? + .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( @@ -24223,13 +25418,17 @@ async fn cleanup_working_directory( request: WorkingDirectoryRemovalRequest, ) -> ApiResult<WorkingDirectoryRemovalResponse> { validate_workspace_scope(&api, &path.workspace_id)?; - execute_workdir_removal( - &api, - &path.working_directory_id, - &source_actor, - &request.reason, - ) - .map_err(ApiError::from) + host_io_executor()? + .run(move || { + execute_workdir_removal( + &api, + &path.working_directory_id, + &source_actor, + &request.reason, + ) + .map_err(ApiError::from) + }) + .await } fn registered_workdir_runtime_id( @@ -24301,12 +25500,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<WorkdirCreateOperationRecord> { + 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<BrowserWorkingDirectoryCreateResponse>)> { +) -> ApiResult<( + WorkingDirectoryRequest, + String, + String, + WorkdirCreateOperationRecord, +)> { validate_workspace_scope(api, workspace_id)?; request.display_name = request .display_name @@ -24465,55 +25701,107 @@ 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<BrowserWorkingDirectoryCreateResponse>)> { + 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 host_io_executor()? + .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 runtime = match api - .runtime - .list_runtimes(usize::MAX) - .items - .into_iter() - .find(|runtime| runtime.runtime_id == reserved.resolved_runtime_id) + let stage_api = api.clone(); + let stage_runtime_id = reserved.resolved_runtime_id.clone(); + let runtime = match host_io_executor()? + .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 { @@ -24525,14 +25813,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, @@ -24547,38 +25835,50 @@ 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 host_io_executor()? + .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 { @@ -24592,24 +25892,29 @@ async fn create_workspace_working_directory( let result = if existing.working_directory.is_some() { existing } else { - match api - .runtime - .create_working_directory(&reserved.resolved_runtime_id, working_directory_request) + let stage_api = api.clone(); + let stage_runtime_id = reserved.resolved_runtime_id.clone(); + match host_io_executor()? + .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 { @@ -24622,18 +25927,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 }) { @@ -24655,21 +25972,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 { @@ -25249,7 +26574,7 @@ async fn set_worker_retention( runtime_worker_id: String, pinned: bool, ) -> ApiResult<Json<WorkerRetentionResponse>> { - 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) @@ -25513,7 +26838,11 @@ 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 = host_io_executor()? + .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 { @@ -25536,20 +26865,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", @@ -25575,7 +26920,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 @@ -25614,12 +26960,19 @@ 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 = host_io_executor()? + .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", @@ -25665,7 +27018,11 @@ 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 = host_io_executor()? + .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(), @@ -25937,7 +27294,9 @@ async fn scoped_restore_runtime_worker_with_context( request: server_api::WorkerRestoreRequest, ) -> ApiResult<Json<server_api::WorkerRestoreResponse>> { 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(); @@ -25970,9 +27329,13 @@ async fn scoped_runtime_cleanup_plan( State(api): State<WorkspaceApi>, AxumPath(path): AxumPath<ScopedRuntimePath>, ) -> ApiResult<Json<RuntimeCleanupPlanResponse>> { - validate_workspace_scope(&api, &path.workspace_id)?; - let plan = build_runtime_cleanup_plan(&api, path.runtime_id.as_str())?; - Ok(Json(plan)) + host_io_executor()? + .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)] @@ -26017,56 +27380,64 @@ async fn scoped_create_attachment_upload_grant( AxumPath(path): AxumPath<ScopedRuntimeWorkerPath>, Query(query): Query<WorkerFileUploadQuery>, ) -> ApiResult<Json<AttachmentUploadGrantResponse>> { - 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 { + host_io_executor()? + .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( @@ -26151,49 +27522,54 @@ async fn scoped_cancel_attachment_upload( Extension(actor): Extension<RequestActor>, AxumPath(path): AxumPath<ScopedAttachmentUploadPath>, ) -> ApiResult<Json<AttachmentUploadCancelResponse>> { - 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 + host_io_executor()? + .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( @@ -26270,8 +27646,13 @@ async fn post_auth_bootstrap_user( State(api): State<ServerAuthApi>, Json(request): Json<AuthBootstrapUserRequest>, ) -> ApiResult<Json<AuthUserResponse>> { - 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( @@ -26279,48 +27660,53 @@ async fn post_passkey_registration_options( headers: HeaderMap, Json(request): Json<PasskeyRegistrationOptionsRequest>, ) -> ApiResult<Json<PasskeyRegistrationOptionsResponse>> { - 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 } fn complete_passkey_registration( @@ -26397,50 +27783,53 @@ async fn post_passkey_login_options( headers: HeaderMap, Json(request): Json<PasskeyLoginOptionsRequest>, ) -> ApiResult<Json<PasskeyLoginOptionsResponse>> { - 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( @@ -26558,48 +27947,53 @@ async fn post_device_login_start( State(api): State<ServerAuthApi>, Json(request): Json<DeviceLoginStartRequest>, ) -> ApiResult<Json<DeviceLoginStartResponse>> { - 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( @@ -26665,41 +28059,48 @@ async fn post_device_login_poll( State(api): State<ServerAuthApi>, Json(request): Json<DeviceLoginPollRequest>, ) -> ApiResult<Json<DeviceLoginPollResponse>> { - 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( @@ -26893,7 +28294,7 @@ async fn resolve_actor( headers: &HeaderMap, ) -> ApiResult<Option<RequestActor>> { 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<RequestActor> { @@ -26985,103 +28386,130 @@ async fn list_tickets( State(api): State<WorkspaceApi>, Query(query): Query<TicketListQuery>, ) -> ApiResult<Json<crate::records::TicketListResponse>> { - 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::<Vec<_>>() + 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::<Vec<_>>() + }) + .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<WorkspaceApi>, AxumPath(id): AxumPath<String>, ) -> ApiResult<Json<TicketDetail>> { - Ok(Json(api.authority.ticket(&id)?)) + api.store + .db_executor() + .run(move || Ok(Json(api.authority.ticket(&id)?))) + .await } async fn list_objectives( State(api): State<WorkspaceApi>, Query(query): Query<server_api::ObjectiveListQuery>, ) -> ApiResult<Json<server_api::ObjectiveListResponse>> { - 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<WorkspaceApi>, AxumPath(id): AxumPath<String>, ) -> ApiResult<Json<ObjectiveDetail>> { - 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<WorkspaceApi>, ) -> ApiResult<Json<RepositoryListResponse>> { - 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.store + .db_executor() + .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<WorkspaceApi>, AxumPath(repository_key): AxumPath<String>, ) -> ApiResult<Json<RepositoryDetailResponse>> { - 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.store + .db_executor() + .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( @@ -27089,63 +28517,79 @@ async fn repository_log( AxumPath(repository_key): AxumPath<String>, Query(query): Query<LogQuery>, ) -> ApiResult<Json<RepositoryLogResponse>> { - 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.store + .db_executor() + .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<WorkspaceApi>, ) -> ApiResult<Json<InternalRuntimeListResponse<InternalHostSummary>>> { - 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, - })) + host_io_executor()? + .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<WorkspaceApi>, ) -> ApiResult<Json<server_api::RuntimeListResponse>> { - 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(), - })) + host_io_executor()? + .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<WorkspaceApi>, ) -> ApiResult<Json<server_api::WorkerListResponse>> { - workers_response(api).map(Json) + api.store + .db_executor() + .run(move || workers_response(api).map(Json)) + .await } async fn create_remote_runtime( @@ -27317,6 +28761,16 @@ fn runtime_removal_config_guard( async fn execute_runtime_removal( api: &WorkspaceApi, operation: RuntimeRemovalOperation, +) -> ApiResult<RuntimeRemovalOperation> { + 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(async move { execute_runtime_removal_on_host(&api, operation).await }).await +} + +async fn execute_runtime_removal_on_host( + api: &WorkspaceApi, + operation: RuntimeRemovalOperation, ) -> ApiResult<RuntimeRemovalOperation> { if operation.state == RuntimeRemovalOperationState::Succeeded { return Ok(operation); @@ -27519,9 +28973,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", @@ -27602,7 +29063,10 @@ async fn test_runtime_connection( async fn get_worker_launch_options( State(api): State<WorkspaceApi>, ) -> ApiResult<Json<WorkerLaunchOptionsResponse>> { - Ok(Json(worker_launch_options_response(&api)?)) + api.store + .db_executor() + .run(move || Ok(Json(worker_launch_options_response(&api)?))) + .await } fn working_directory_request_from_repository( @@ -28418,30 +29882,44 @@ fn backend_resource_error_status(error: &BackendResourceError) -> StatusCode { async fn get_companion_status( State(api): State<WorkspaceApi>, ) -> ApiResult<Json<CompanionStatusResponse>> { - 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<WorkspaceApi>, Query(query): Query<TranscriptQuery>, ) -> ApiResult<Json<CompanionTranscriptProjection>> { - 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<WorkspaceApi>, Json(request): Json<CompanionMessageRequest>, ) -> ApiResult<Json<CompanionMessageResponse>> { - 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<WorkspaceApi>, Json(request): Json<CompanionCancelRequest>, ) -> ApiResult<Json<CompanionMessageResponse>> { - Ok(Json(api.companion.cancel(request))) + api.store + .db_executor() + .run(move || Ok(Json(api.companion.cancel(request)))) + .await } fn runtime_contract_restore<T: serde::Serialize, U: serde::de::DeserializeOwned>( @@ -28533,25 +30011,29 @@ async fn get_runtime_worker( State(api): State<WorkspaceApi>, AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, ) -> ApiResult<Json<server_api::RuntimeWorkerShowResponse>> { - 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, - })) + host_io_executor()? + .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( @@ -28561,7 +30043,7 @@ async fn restore_runtime_worker_with_context( context: WorkerOperationContext, request: server_api::WorkerRestoreRequest, ) -> ApiResult<Json<server_api::WorkerRestoreResponse>> { - let worker = WorkspaceWorker::resolve(&api, &runtime_id, &worker_id)?; + let worker = WorkspaceWorker::resolve_async(&api, &runtime_id, &worker_id).await?; 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. @@ -28626,33 +30108,37 @@ async fn list_runtime_workers( AxumPath(runtime_id): AxumPath<String>, Query(query): Query<RuntimeWorkersQuery>, ) -> ApiResult<Json<server_api::RuntimeWorkerListResponse>> { - 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(), - })) + host_io_executor()? + .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)] @@ -29219,17 +30705,21 @@ async fn sync_runtime_config_bundle( AxumPath(runtime_id): AxumPath<String>, Json(request): Json<server_api::RuntimeConfigBundleSyncRequest>, ) -> ApiResult<Json<server_api::RuntimeConfigBundleResult>> { - let bundle = serde_json::from_value::<ConfigBundle>(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, - )?)) + host_io_executor()? + .run(move || { + let bundle = serde_json::from_value::<ConfigBundle>(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( @@ -29237,21 +30727,25 @@ async fn check_runtime_config_bundle( AxumPath((runtime_id, bundle_id)): AxumPath<(String, String)>, Query(query): Query<server_api::RuntimeConfigBundleAvailabilityQuery>, ) -> ApiResult<Json<server_api::RuntimeConfigBundleResult>> { - 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, - )?)) + host_io_executor()? + .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)] @@ -29271,7 +30765,7 @@ async fn dispatch_runtime_worker_input( worker_id: String, request: WorkerInputRequest, ) -> ApiResult<Json<server_api::RuntimeWorkerInputResult>> { - 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?; @@ -29282,11 +30776,15 @@ async fn delete_runtime_worker_uploaded_file( State(api): State<WorkspaceApi>, AxumPath((runtime_id, worker_id, artifact_id)): AxumPath<(String, String, String)>, ) -> ApiResult<Json<WorkerFileDeleteResponse>> { - 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 })) + host_io_executor()? + .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( @@ -29294,13 +30792,17 @@ async fn runtime_worker_completions( AxumPath((runtime_id, worker_id)): AxumPath<(String, String)>, Json(request): Json<server_api::RuntimeWorkerCompletionsRequest>, ) -> ApiResult<Json<server_api::RuntimeWorkerCompletionsResult>> { - 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)?)) + host_io_executor()? + .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 { @@ -29361,7 +30863,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(), }; @@ -29869,23 +31371,27 @@ async fn list_host_workers( State(api): State<WorkspaceApi>, AxumPath(host_id): AxumPath<String>, ) -> ApiResult<Json<server_api::HostWorkerListResponse>> { - 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(), - })) + host_io_executor()? + .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( @@ -32444,9 +33950,27 @@ impl From<merge_request::MergeRequestError> for ApiError { } } +impl From<Error> for server_api::RepositoryApiError { + fn from(error: Error) -> Self { + ApiError::from(error).into_repository_api_error() + } +} + impl From<Error> 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, @@ -32670,6 +34194,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 @@ -32727,6 +34253,10 @@ impl IntoResponse for ApiError { } } +#[cfg(test)] +#[path = "server_db_execution_tests.rs"] +mod db_execution_tests; + #[cfg(test)] mod tests { mod assignment_tracking_tests { @@ -33663,6 +35193,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(); @@ -33856,10 +35453,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") @@ -34404,10 +36001,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 { @@ -34830,11 +36428,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 @@ -56217,7 +57819,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, @@ -56278,7 +57880,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, @@ -56296,7 +57898,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, diff --git a/crates/workspace-server/src/server/subjektiv_job.rs b/crates/workspace-server/src/server/subjektiv_job.rs index 0fcb072af..6aaa1e83c 100644 --- a/crates/workspace-server/src/server/subjektiv_job.rs +++ b/crates/workspace-server/src/server/subjektiv_job.rs @@ -65,70 +65,83 @@ 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<bool> { + 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) = host_io_executor()? + .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 +154,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/auth_logging_tests.rs b/crates/workspace-server/src/server/tests/auth_logging_tests.rs index b4b51d7bc..b00d2e1b4 100644 --- a/crates/workspace-server/src/server/tests/auth_logging_tests.rs +++ b/crates/workspace-server/src/server/tests/auth_logging_tests.rs @@ -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 diff --git a/crates/workspace-server/src/server/tests/protocol_backpressure_tests.rs b/crates/workspace-server/src/server/tests/protocol_backpressure_tests.rs index 7a4066b39..358b9a7bb 100644 --- a/crates/workspace-server/src/server/tests/protocol_backpressure_tests.rs +++ b/crates/workspace-server/src/server/tests/protocol_backpressure_tests.rs @@ -767,6 +767,9 @@ async fn assert_stalled_browser_output_reclaimed(explicit_termination: bool) { 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; } @@ -844,7 +847,7 @@ async fn embedded_disconnect_and_last_owner_drop_release_the_execution_event_sub } } -#[tokio::test(start_paused = true)] +#[tokio::test] async fn stalled_remote_bridge_browser_cancel_overflow_reclaims_wire_and_allows_same_worker_progress() { let workspace = tempfile::tempdir().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..c71ea73df 100644 --- a/crates/workspace-server/src/server/tests/workspace_admission_tests.rs +++ b/crates/workspace-server/src/server/tests/workspace_admission_tests.rs @@ -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/worker_operations.rs b/crates/workspace-server/src/server/worker_operations.rs index 35b60148f..ed38158bf 100644 --- a/crates/workspace-server/src/server/worker_operations.rs +++ b/crates/workspace-server/src/server/worker_operations.rs @@ -57,6 +57,19 @@ impl WorkerOperationContext { } Ok(()) } + pub(super) async fn from_request_async( + api: &WorkspaceApi, + context: &server_api::ServerRequestContext, + ) -> std::result::Result<Self, server_api::RepositoryApiError> { + 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, @@ -162,8 +175,20 @@ impl<Services> WorkspaceWorker<Services> { source: crate::worker_source::VerifiedWorkerMutationSource, reason: &str, ) -> std::result::Result<worker::WorkspaceResponse, String> { - 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, @@ -225,6 +250,20 @@ impl crate::worker_source::VerifiedWorkerRemoveExecutor for EmbeddedWorkspaceWor } impl WorkspaceWorker { + pub(crate) async fn resolve_async( + api: &WorkspaceApi, + runtime_id: &str, + reference: &str, + ) -> Result<Self> { + 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> { Self::resolve_with_services( api.clone(), @@ -239,7 +278,36 @@ impl WorkspaceWorker { context: &WorkerOperationContext, permission: &str, ) -> ApiResult<Option<tokio::sync::OwnedMutexGuard<()>>> { - 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<Option<WorkerControlGrantRecord>> { + 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), @@ -248,42 +316,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 } } } @@ -293,8 +354,14 @@ impl WorkspaceWorker { context: &WorkerOperationContext, request: server_api::WorkerRestoreRequest, ) -> ApiResult<InternalWorkerRestoreResult> { - let _guard = self.authorize_operation(context, "restore").await?; - self.restore_internal(request) + let guard = self.authorize_operation(context, "restore").await?; + let worker = self.clone(); + host_io_executor()? + .run(move || { + let _guard = guard; + worker.restore_internal(request) + }) + .await } /// Trusted Backend callers still supply an explicit observed intent. Runtime @@ -312,27 +379,43 @@ impl WorkspaceWorker { context: &WorkerOperationContext, request: WorkerLifecycleRequest, ) -> ApiResult<WorkerLifecycleResult> { - let _guard = self.authorize_operation(context, "stop").await?; - let result = self - .api - .runtime - .stop_worker(&self.identity, request) - .map_err(|e| e.into_error())?; + let guard = self.authorize_operation(context, "stop").await?; + let worker = self.clone(); + let (result, guard) = host_io_executor()? + .run(move || -> ApiResult<_> { + let result = worker + .api + .runtime + .stop_worker(&worker.identity, request) + .map_err(|e| e.into_error())?; + Ok((result, guard)) + }) + .await?; 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; + let session_guard = session_lock.lock_owned().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 worker = self.clone(); + host_io_executor()? + .run(move || -> ApiResult<_> { + let (_control, _session) = (guard, session_guard); + 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( @@ -340,12 +423,18 @@ impl WorkspaceWorker { context: &WorkerOperationContext, request: WorkerLifecycleRequest, ) -> ApiResult<WorkerLifecycleResult> { - 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(); + host_io_executor()? + .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( @@ -358,46 +447,59 @@ 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(); + host_io_executor()? + .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( @@ -405,33 +507,44 @@ impl WorkspaceWorker { context: &WorkerOperationContext, pinned: bool, ) -> ApiResult<WorkerRetentionResponse> { - 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<WorkerRetentionResponse> { + // 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 @@ -611,7 +724,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(), diff --git a/crates/workspace-server/src/server/workspace_admission.rs b/crates/workspace-server/src/server/workspace_admission.rs index 06c94b9a8..5f8335629 100644 --- a/crates/workspace-server/src/server/workspace_admission.rs +++ b/crates/workspace-server/src/server/workspace_admission.rs @@ -72,6 +72,28 @@ pub(super) struct RequestLease { callback: bool, } +impl crate::db_executor::DbOperationLifetime for RequestLease { + fn retain(&self) -> Box<dyn Send> { + // 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_db_execution_tests.rs b/crates/workspace-server/src/server_db_execution_tests.rs new file mode 100644 index 000000000..22052925a --- /dev/null +++ b/crates/workspace-server/src/server_db_execution_tests.rs @@ -0,0 +1,606 @@ +//! Real SQLite waits at the HTTP/authority boundary (not mock executor jobs). +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); + +async fn observed<T>(future: impl Future<Output = T>) -> T { + tokio::time::timeout(OBSERVE_LIMIT, future) + .await + .expect("request did not reach the expected DB/lifecycle state") +} + +struct Fixture { + // Keep temporary authority files alive until all other fixture owners drop. + server: WorkspaceServerApi, + api: WorkspaceApi, + executor: DbExecutor, + _dir: tempfile::TempDir, +} + +impl Fixture { + async fn new() -> 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: Vec::new(), + runtime_event_sources: Vec::new(), + remote_runtime_sources: Vec::new(), + backend_base_url: Some("http://localhost:8787".into()), + }; + let store = Arc::new( + SqliteWorkspaceStore::open(&config.database_path) + .unwrap() + .with_db_executor(executor.clone()), + ); + seed_test_registered_workspace(store.as_ref(), &config) + .await + .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(); + let api = WorkspaceApi::new(config.clone(), store.clone()) + .await + .unwrap(); + let server = WorkspaceServerApi::new(config, store); + // 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, + _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<Response> { + 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<mpsc::Sender<()>>, + thread: Option<JoinHandle<()>>, + released: Arc<AtomicBool>, +} + +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<oneshot::Sender<()>>, + 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. +struct BusyObservation { + database: crate::FeatureDatabase, + gate: Box<BusyGate>, + resume: Option<mpsc::Sender<()>>, +} +impl BusyObservation { + fn install(database: crate::FeatureDatabase) -> (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::<BusyGate>() }; + 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 + .with_connection(|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); + Ok(()) + }) + .unwrap(); + ( + 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 + .with_connection(|conn| { + // SAFETY: unregister before the boxed userdata is freed. + unsafe { + ffi::sqlite3_busy_handler(conn.handle(), None, std::ptr::null_mut()); + } + Ok(()) + }) + .unwrap(); + 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<Option<oneshot::Sender<()>>>); +impl DbOperationLifetime for AdmissionObservation { + fn retain(&self) -> Box<dyn Send> { + if let Some(accepted) = self.0.lock().unwrap().take() { + let _ = accepted.send(()); + } + Box::new(()) + } +} +fn admission_observation() -> (Arc<dyn DbOperationLifetime>, 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<Mutex<Vec<mpsc::Sender<()>>>>, +} +impl DbOperationLifetime for StateCheckSaturation { + fn retain(&self) -> Box<dyn Send> { + 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<Mutex<Vec<mpsc::Sender<()>>>>); +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(host_io_executor().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 DB operation: {body}" + ); + fixture.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.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.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.executor.drain().await.unwrap(); + assert_eq!( + subject_count(&path, "after-drain"), + 1, + "drain must release actual-job capacity" + ); +} diff --git a/crates/workspace-server/src/server_drive.rs b/crates/workspace-server/src/server_drive.rs index 57c79ef7f..33bb2e908 100644 --- a/crates/workspace-server/src/server_drive.rs +++ b/crates/workspace-server/src/server_drive.rs @@ -51,14 +51,13 @@ async fn blocking<T: Send + 'static>( mutation: bool, action: impl FnOnce() -> workspace_drive::Result<T> + Send + 'static, ) -> DriveResult<T> { - 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 9d85eaf67..4d1f82fe5 100644 --- a/crates/workspace-server/src/server_drive_grants.rs +++ b/crates/workspace-server/src/server_drive_grants.rs @@ -24,7 +24,10 @@ pub(super) fn pre_error(error: impl Into<ApiError>) -> 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) @@ -97,23 +100,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( @@ -152,9 +159,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) } @@ -175,13 +183,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<()> { @@ -212,12 +219,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_workspace_config.rs b/crates/workspace-server/src/server_workspace_config.rs index 0e0a54959..dbdb24e6c 100644 --- a/crates/workspace-server/src/server_workspace_config.rs +++ b/crates/workspace-server/src/server_workspace_config.rs @@ -47,7 +47,10 @@ pub(super) fn pre_error(error: impl Into<ApiError>) -> 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) @@ -186,9 +189,23 @@ pub(super) async fn get( api: &WorkspaceApi, worker: &RuntimeWorkerRef, ) -> ConfigResult<Option<WorkspaceConfigAttachment>> { - 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, @@ -198,46 +215,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, @@ -313,9 +343,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() @@ -427,9 +470,22 @@ pub(super) async fn read( request: WorkspaceConfigReadRequest, ) -> ConfigResult<WorkspaceConfigReadResponse> { 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() @@ -482,12 +538,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() @@ -501,17 +570,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 @@ -520,15 +591,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 = host_io_executor().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, @@ -544,37 +678,59 @@ 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) = host_io_executor() + .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, @@ -585,39 +741,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( @@ -655,7 +832,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..72fb9e910 100644 --- a/crates/workspace-server/src/server_workspace_config_tests.rs +++ b/crates/workspace-server/src/server_workspace_config_tests.rs @@ -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() ); diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index c88faeec8..b5cfd5cec 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1123,6 +1123,10 @@ impl WorkspaceResourceKind { #[async_trait] pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { + fn db_executor(&self) -> crate::db_executor::DbExecutor { + crate::db_executor::DbExecutor::shared() + } + async fn schema_version(&self) -> Result<i64>; fn resource_key( &self, @@ -2179,6 +2183,7 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { #[derive(Clone)] pub struct SqliteWorkspaceStore { conn: Arc<Mutex<Connection>>, + db_executor: crate::db_executor::DbExecutor, } impl SqliteWorkspaceStore { @@ -2222,9 +2227,20 @@ impl SqliteWorkspaceStore { prepare_connection(&conn)?; Ok(Self { conn: Arc::new(Mutex::new(conn)), + db_executor: crate::db_executor::DbExecutor::shared(), }) } + /// 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<T>(&self, f: impl FnOnce(&Connection) -> Result<T>) -> Result<T> { let conn = self .conn @@ -3794,8 +3810,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<i64> { - 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( @@ -3927,100 +3951,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<Option<WorkspaceRecord>> { - 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( @@ -4029,58 +4067,68 @@ impl ControlPlaneStore for SqliteWorkspaceStore { expected_updated_at: &str, display_name: &str, ) -> Result<Option<WorkspaceRecord>> { - 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( @@ -4600,7 +4648,17 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, runtime_id: &str, ) -> Result<Option<WorkspaceRuntimeBinding>> { - 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( @@ -4608,7 +4666,19 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, include_revoked: bool, ) -> Result<Vec<WorkspaceRuntimeBinding>> { - 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( @@ -4616,11 +4686,21 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, runtime_id: &str, ) -> Result<bool> { - 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( @@ -4628,7 +4708,21 @@ impl ControlPlaneStore for SqliteWorkspaceStore { workspace_id: &str, runtime_id: &str, ) -> Result<bool> { - 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( @@ -4640,22 +4734,48 @@ impl ControlPlaneStore for SqliteWorkspaceStore { expected_binding_id: &str, config_digest: &str, ) -> Result<RuntimeRemovalReservation> { - 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<Option<RuntimeRemovalOperation>> { - 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( @@ -4663,14 +4783,36 @@ impl ControlPlaneStore for SqliteWorkspaceStore { operation_id: &str, failure_category: &str, ) -> Result<RuntimeRemovalOperation> { - 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<RuntimeRemovalOperation> { - 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( @@ -4678,18 +4820,34 @@ impl ControlPlaneStore for SqliteWorkspaceStore { operation_id: &str, runtime_registration_removed: bool, ) -> Result<RuntimeRemovalOperation> { - 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<Vec<RuntimeRemovalOperation>> { - 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( @@ -4697,7 +4855,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record: WorkspaceRuntimeBinding, replace: bool, ) -> Result<WorkspaceRuntimeBindingUpsert> { - 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( @@ -4706,12 +4871,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore { runtime_id: &str, revoked_at: &str, ) -> Result<bool> { - 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( @@ -4720,12 +4897,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( @@ -4736,14 +4923,30 @@ impl ControlPlaneStore for SqliteWorkspaceStore { base_url: &str, updated_at: &str, ) -> Result<WorkspaceRuntimeBinding> { - 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( @@ -4754,14 +4957,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( @@ -4770,12 +4989,22 @@ impl ControlPlaneStore for SqliteWorkspaceStore { runtime_id: &str, limit: usize, ) -> Result<Vec<WorkspaceRuntimeBindingAuditRecord>> { - 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( @@ -4787,21 +5016,35 @@ impl ControlPlaneStore for SqliteWorkspaceStore { now_seconds: u64, consumed_at: &str, ) -> Result<bool> { - 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( diff --git a/crates/workspace-server/src/worker_projection.rs b/crates/workspace-server/src/worker_projection.rs index ed910de04..cb6a10dde 100644 --- a/crates/workspace-server/src/worker_projection.rs +++ b/crates/workspace-server/src/worker_projection.rs @@ -5,7 +5,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 +59,14 @@ pub struct WorkerProjectionService { events: broadcast::Sender<WorkerProjectionEvent>, ordering: Mutex<()>, attach_gate: tokio::sync::Mutex<()>, - tasks: Mutex<HashMap<String, JoinHandle<()>>>, + tasks: Mutex<HashMap<String, RuntimeProjectionLifetime>>, +} + +struct RuntimeProjectionLifetime { + consumer: Option<JoinHandle<()>>, + // 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<Semaphore>, } impl WorkerProjectionService { @@ -75,17 +82,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::<Vec<_>>(); + 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<Semaphore> { + 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<Self>, + limit: usize, + ) -> crate::Result<WorkerProjectionSubscription> { + let service = self.clone(); + self.store + .db_executor() + .run(move || service.subscribe_ordered(limit)) + .await + } + pub fn subscribe_ordered(&self, limit: usize) -> crate::Result<WorkerProjectionSubscription> { let _ordering = self .ordering @@ -125,19 +204,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 +218,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 +252,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 +282,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<Self>, + 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<Self>, 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<Self>, + runtime_id: &str, + event: BrokerSubscriptionEvent, + operations: Arc<Semaphore>, + ) -> 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 +462,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(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 +508,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<std::sync::mpsc::Sender<()>>, + controller: Option<std::thread::JoinHandle<()>>, + } + + 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 3d51476d1..3edbb5d3e 100644 --- a/crates/workspace-server/src/worker_source.rs +++ b/crates/workspace-server/src/worker_source.rs @@ -70,10 +70,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<crate::Error> 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<'_>, @@ -107,7 +121,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(); @@ -151,15 +165,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 @@ -172,7 +194,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 9e79fd3b1..d299b520d 100644 --- a/crates/workspace-server/src/worker_source/runtime_request.rs +++ b/crates/workspace-server/src/worker_source/runtime_request.rs @@ -1,3 +1,5 @@ +use std::sync::Arc; + use worker_runtime::auth::{ RuntimeAuthError, RuntimeRequestClaim, RuntimeRequestSourceClaims, RuntimeRequestSourceExpectation, decode_runtime_request_source_claims, @@ -33,9 +35,21 @@ pub struct RuntimeRequestProofError { pub(crate) exp: Option<i64>, pub(crate) now: Option<i64>, pub(crate) time_delta_seconds: Option<u64>, + /// 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", @@ -47,6 +61,7 @@ impl RuntimeRequestProofError { exp: None, now: None, time_delta_seconds: None, + availability: false, } } @@ -106,6 +121,18 @@ impl RuntimeRequestProofError { } } +impl From<crate::Error> 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 { @@ -130,7 +157,7 @@ pub async fn verify_runtime_request_source_proof( body_digest: &str, ) -> Result<VerifiedRuntimeRequestSource, RuntimeRequestProofError> { verify_runtime_request_source_proof_with_store( - api.store.as_ref(), + Arc::clone(&api.store), &api.config, proof, workspace_id, @@ -144,7 +171,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<dyn ControlPlaneStore>, config: &ServerConfig, proof: &str, workspace_id: &str, @@ -169,7 +196,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<dyn ControlPlaneStore>, config: &ServerConfig, proof: &str, workspace_id: &str, @@ -182,15 +209,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(|| { @@ -238,7 +268,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")); } @@ -247,29 +281,58 @@ 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 { @@ -279,3 +342,290 @@ pub(crate) async fn verify_runtime_request_source_proof_with_clock( 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<Option<( + tokio::sync::oneshot::Sender<()>, + 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<SqliteWorkspaceStore>, 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(&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_drive_host.rs b/crates/workspace-server/src/workspace_drive_host.rs index 410c0c049..bc0bfc390 100644 --- a/crates/workspace-server/src/workspace_drive_host.rs +++ b/crates/workspace-server/src/workspace_drive_host.rs @@ -58,10 +58,13 @@ pub(crate) struct WorkspaceDriveHost { impl WorkspaceDriveHost { pub(crate) async fn new(storage: &WorkspaceFeatureStorage) -> Result<Self> { 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), @@ -80,21 +83,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) } @@ -109,9 +112,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( @@ -124,20 +127,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_subscription.rs b/crates/workspace-server/src/workspace_subscription.rs index 8b0e0b818..eaa88d40e 100644 --- a/crates/workspace-server/src/workspace_subscription.rs +++ b/crates/workspace-server/src/workspace_subscription.rs @@ -117,11 +117,13 @@ pub(crate) async fn serve_workspace_subscription<S>( worker_id, runtime_id: Some(runtime_id), } => { - let connection = match WorkspaceWorker::resolve( + let connection = match WorkspaceWorker::resolve_async( &api, &runtime_id, worker_id.as_str(), - ) { + ) + .await + { Ok(worker) => worker .connect_protocol(&context) .await @@ -366,7 +368,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, @@ -377,11 +380,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::<Vec<_>>(); + 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, @@ -406,7 +420,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 } @@ -450,8 +486,28 @@ async fn run_workspace_workers( } } +async fn project_registry_workers_async( + store: std::sync::Arc<dyn crate::store::ControlPlaneStore>, + workspace_id: String, + records: Vec<crate::store::WorkerRegistryProjectionRecord>, +) -> crate::Result<Vec<SubscriptionWorker>> { + 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<SubscriptionWorker> { let runtime_id = record.registry.worker.runtime_id.clone(); @@ -491,29 +547,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; }; @@ -533,9 +585,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; }; @@ -543,13 +594,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) @@ -634,9 +689,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()), @@ -660,8 +714,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") })); @@ -673,6 +731,109 @@ 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().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<protocol::Event>) -> SubscriptionResponse { let (control_outbound, mut control_receiver) = mpsc::channel(1); let (protocol_outbound, _protocol_receiver) = mpsc::channel(1); diff --git a/docs/development/backend-db-execution.md b/docs/development/backend-db-execution.md new file mode 100644 index 000000000..dd8f1241e --- /dev/null +++ b/docs/development/backend-db-execution.md @@ -0,0 +1,95 @@ +# Backend bounded synchronous execution + +## Ownership and admission + +`ControlPlaneStore::db_executor()` returns a clone of the bounded execution owner. +Use `executor.run(move || synchronous_operation(...)).await` with owned input and +services. The closure owns the **whole** synchronous operation, including connection +acquisition, mutex wait, the existing SQL transaction, commit, and ordered local +publication. Do not split source authorization, receipts, CAS, audit, or saved +results around an await. Do not change connection ownership or introduce a read pool. + +Production stores share one DB worker and a queue of 32 jobs. Admission uses a +nonblocking queue send and a separate actual-job budget (worker count plus queue +capacity); rejection means the operation was **not accepted**. HTTP maps capacity +and executor unavailability to 503, not authentication failure or deletion conflict. +Tests can inject a dedicated executor with explicit limits, without process-global +settings or environment variables. + +Accepted work is owned by the worker, not the Tokio waiter. Cancelling a waiter: + +- does not interrupt, resubmit, or retry SQL; +- does not return its queue/job slot before execution and discarded-result cleanup; +- does not release the request lifecycle lease before actual completion. + +Synchronous post-commit continuations inherit the admitted lifecycle source when +submitting another bounded job. This is not implicit inheritance by detached Tokio +tasks. An operation panic has an unknown outcome even if SQL already committed; +return a structured unknown-outcome error and diagnostic, never retry the mutation. +A cancelled fire-and-forget job still emits its diagnostic. `drain()` observes all +jobs accepted before its barrier is first polled and waits for actual cleanup; it +is not a queue close and does not include later admissions. Lifecycle callers must +stop their producers/admission before destroying resources. + +## Runtime/network boundaries + +A synchronous Runtime workflow must not monopolize the sole DB worker while +waiting for callbacks that themselves need DB authority. The Server's separate +finite Host lane has two workers and 16 queued jobs. It uses the same accepted-job +ownership mechanism, not an unbounded `spawn_blocking` queue. + +Pure DB stages use the DB lane. Existing mixed resource-lifecycle workflows can +use the Host lane, with no Runtime I/O or await inside a SQL transaction. Where an +owned workflow needs async resource locks, `run_host_workflow` polls it on a Host +worker using the original Tokio handle; do not build a replacement runtime for +cached services and do not submit to and await the Host lane from inside itself. +Control/session guards needed by an accepted mutation belong to that actual job. + +Backend Job cleanup is admitted before its durable cleanup claim. Rejection leaves +Pending/Failed ownership recoverable instead of abandoning a Running claim. Ticket +checker dispatch and timeout processing use bounded admission; overload leaves the +saved reservation/deadline diagnosable. They are not automatically retried after an +unknown DB outcome. Runtime teardown is still its own lifecycle fence, not proof +of model processing. + +Remote subscription and Workdir authorization are async callbacks: their complete +live binding check and fresh request signing are offloaded together. A queue error +must not fall back to a configured bearer token. Synchronous CLI/migration callers +and existing synchronous Runtime adapters retain their explicit blocking boundary. + +## Caller recipe (including assignment APIs) + +```rust +let store = api.store.clone(); +let workspace_id = api.workspace_id().to_owned(); +let worker = worker.clone(); +let record = api.store.db_executor().run(move || { + store.get_worker_registry(&workspace_id, &worker) +}).await?; +``` + +This is an illustration, not a new authority API. Assignment delivery scan, +reserve, prepare, acknowledgement and start authorization must keep their existing +whole transaction helpers. Additional assignment diagnostics, answer mutations, +and Ticket queries use the same owned caller boundary; do not add a second ledger +or reconstruct processing evidence from Worker liveness. + +## Regression evidence + +- `db_executor::tests`: real SQLite IMMEDIATE write contention while a current-thread + timer/control task progresses; accepted/cancelled queue bounds; rollback; panic + before/after commit; discarded reply cleanup and retained authority; child jobs. +- `server::db_execution_tests`: HTTP Ticket writes and cold/warm Subjektiv scope + opening/mutation under a separate SQLite connection's write lock; overload; + cancelled HTTP jobs and Workspace deletion drain. +- `worker_projection::tests`: real SQL contention, commit/payload publication, + ordered snapshot registration, and replacement draining a cancelled consumer's + accepted SQL before applying a new Runtime snapshot. +- Auth/proof and Runtime subscription tests protect availability classification, + replay rejection, and no bearer fallback. +- `workdir`'s `http-client` tests protect awaited fresh authorization and exact + request claims before any HTTP send. + +These fixtures use temporary authority data and observable synchronization, not +live databases or a fixed sleep to guess completion. This change does not claim a +latency benchmark or a measured production incident. From 9b0d42fdbc2920736c3418bb894f26140e962bbc Mon Sep 17 00:00:00 2001 From: Hare <kei.hiracchi.0928@gmail.com> Date: Mon, 12 Oct 2026 07:33:11 +0900 Subject: [PATCH 18/39] feat(ticket): expose case tags and independent assignment handling --- crates/server-api/src/lib.rs | 142 +++ .../src/repository_openapi_typescript.rs | 42 + crates/ticket/src/assignment_answer.rs | 117 +++ crates/ticket/src/assignment_tracking.rs | 50 + .../ticket/src/assignment_tracking_tests.rs | 101 ++ crates/ticket/src/lib.rs | 2 + crates/worker-runtime/src/worker_source.rs | 184 ++++ crates/worker/src/feature/builtin/ticket.rs | 153 ++- .../builtin/ticket/assignment_public.rs | 340 ++++++ .../builtin/ticket/assignment_public/tests.rs | 598 +++++++++++ .../builtin/ticket/assignment_tracking.rs | 2 +- crates/worker/src/prompt/catalog.rs | 7 +- crates/worker/src/prompt/system.rs | 166 ++- crates/worker/src/worker.rs | 131 +++ .../src/assignment_delivery.rs | 90 +- .../src/assignment_delivery_tests.rs | 257 ++++- .../src/assignment_tracking.rs | 42 + .../src/assignment_tracking_tests.rs | 339 +++++- crates/workspace-server/src/authority.rs | 112 +- crates/workspace-server/src/server.rs | 198 +++- .../src/server_assignment_attention_tests.rs | 30 +- .../src/server_assignment_delivery_tests.rs | 133 ++- .../src/server_assignment_tracking_tests.rs | 575 ++++++++++- crates/workspace-server/src/store.rs | 68 ++ docs/design/flow-state-graph.md | 2 +- docs/design/merge-request-wip-projection.md | 2 +- docs/design/orchestrator-worktree-layout.md | 2 + docs/design/ticket-evidence.md | 6 +- docs/design/ticket-wip-projection.md | 43 +- .../workspace-kanban-orchestrator-runtime.md | 2 + .../development/ticket-assignment-delivery.md | 6 +- .../development/ticket-assignment-tracking.md | 4 +- docs/development/work-items.md | 20 +- docs/report/T-743-T748-callers.md | 78 ++ docs/report/T-743-assignment-tools-handoff.md | 51 + docs/report/T-743-prompt-surfaces.md | 64 ++ .../report/T-743-public-surface-validation.md | 58 ++ openapi/server-api.json | 962 ++++++++++++++++- resources/flows/coder-review.dcdl | 2 +- resources/profiles/intake.dcdl | 2 +- resources/profiles/orchestrator.dcdl | 2 +- resources/prompts/common/tickets.md | 12 +- .../panel/orchestrator_idle_queue_notice.md | 6 +- resources/prompts/role/coder.md | 2 +- resources/prompts/role/intake.md | 2 +- resources/prompts/role/orchestrator.md | 4 +- resources/prompts/role/ticket-worker.md | 2 +- .../browser-tests/ticket_fixture_server.ts | 972 +++++++++++++++++- .../browser-tests/ticket_principal_test.ts | 65 ++ .../web-ux/browser-tests/ticket_shell_test.ts | 921 +++++++++++++---- tools/web-ux/deno.json | 2 +- .../scenarios/ticket-principals-dark.json | 123 +++ tools/web-ux/scenarios/ticket-principals.json | 123 +++ tools/web-ux/scenarios/tickets-dark.json | 227 +++- tools/web-ux/scenarios/tickets.json | 227 +++- web/workspace/deno.json | 2 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 18 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../src/lib/workspace/api/ticket-browser.ts | 160 ++- .../workspace/api/ticket-operations.test.ts | 275 +++++ .../lib/workspace/api/ticket-operations.ts | 293 ++++++ .../console/worker-console.ui.test.ts | 21 +- .../workspace/home/dashboard.test-fixtures.ts | 1 + .../workspace/tickets/TicketHandling.svelte | 219 ++++ .../lib/workspace/tickets/TicketTags.svelte | 100 ++ .../tickets/TicketWorkerObservation.svelte | 37 + .../tickets/ticket-operations.browser.spec.ts | 214 ++++ .../workspace/tickets/ticket-panel.test.ts | 127 ++- .../src/lib/workspace/tickets/ticket-panel.ts | 160 ++- .../w/[workspaceId]/orchestrator/+page.svelte | 85 ++ .../w/[workspaceId]/orchestrator/+page.ts | 55 + .../w/[workspaceId]/tickets/+page.svelte | 146 ++- .../routes/w/[workspaceId]/tickets/+page.ts | 94 +- .../tickets/[ticketId]/+page.svelte | 132 +-- .../test/ticket-detail-route-reuse.test.ts | 17 +- 79 files changed, 9326 insertions(+), 713 deletions(-) create mode 100644 crates/ticket/src/assignment_answer.rs create mode 100644 crates/worker/src/feature/builtin/ticket/assignment_public.rs create mode 100644 crates/worker/src/feature/builtin/ticket/assignment_public/tests.rs create mode 100644 docs/report/T-743-T748-callers.md create mode 100644 docs/report/T-743-assignment-tools-handoff.md create mode 100644 docs/report/T-743-prompt-surfaces.md create mode 100644 docs/report/T-743-public-surface-validation.md create mode 100644 tools/web-ux/browser-tests/ticket_principal_test.ts create mode 100644 tools/web-ux/scenarios/ticket-principals-dark.json create mode 100644 tools/web-ux/scenarios/ticket-principals.json create mode 100644 web/workspace/src/lib/workspace/api/ticket-operations.test.ts create mode 100644 web/workspace/src/lib/workspace/api/ticket-operations.ts create mode 100644 web/workspace/src/lib/workspace/tickets/TicketHandling.svelte create mode 100644 web/workspace/src/lib/workspace/tickets/TicketTags.svelte create mode 100644 web/workspace/src/lib/workspace/tickets/TicketWorkerObservation.svelte create mode 100644 web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts create mode 100644 web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.svelte create mode 100644 web/workspace/src/routes/w/[workspaceId]/orchestrator/+page.ts diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 87eaa3a01..1a67f3544 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -362,6 +362,12 @@ impl_openapi_schema!( TicketOrchestrationPlanRecord, TicketOrchestrationPlanRecordList, TicketOrchestrationPlanSearchRequest, + TicketAssignmentsQuery, + TicketAssignmentsPage, + TicketAssignmentAnswerRequest, + TicketAssignmentAnswerResponse, + TicketAssignmentDiagnosticsQuery, + TicketAssignmentDiagnosticsPage, TicketQueryRequest, TicketQueryResponse, TicketQueueResponse, @@ -3198,6 +3204,60 @@ pub trait ServerApi { #[path] ticket_id: String, #[body] request: TicketCaseUpdateRequest, ) -> Result<TicketSummary, RepositoryApiError>; + #[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] ticket_id: String, + #[query] query: TicketAssignmentsQuery, + ) -> Result<TicketAssignmentsPage, RepositoryApiError>; + + #[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<TicketAssignmentsPage, RepositoryApiError>; + + #[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<TicketAssignmentDiagnosticsPage, RepositoryApiError>; + + #[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<TicketAssignmentDiagnosticsPage, RepositoryApiError>; + + #[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<TicketAssignmentAnswerResponse, RepositoryApiError>; + + #[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<TicketAssignmentAnswerResponse, RepositoryApiError>; + #[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, @@ -7604,6 +7664,21 @@ impl TicketAttention { #[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<ticket::TicketClosureReason>, + /// Exact current assigned principal; retained relationships do not match. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketAssignmentPrincipalSnapshot | null") + )] + pub assigned_principal: Option<ticket::TicketAssignmentPrincipalSnapshot>, + /// Explicit self-reported handling only; never inferred from Worker state. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketAssignmentTrackingStatus | null") + )] + pub handling_status: Option<ticket::TicketAssignmentTrackingStatus>, pub query: Option<String>, #[serde(default)] #[cfg_attr(feature = "typescript", ts(type = "Array<BrowserTicketState>"))] @@ -7860,6 +7935,73 @@ pub struct TicketAssignmentTrackingHistoryPage { pub next_event_id: Option<String>, } +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketAssignmentsQuery { + pub after_assignment_id: Option<String>, + #[schemars(range(min = 1, max = 100))] + pub limit: Option<usize>, +} +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<String>, + pub after_start_operation_id: Option<String>, + #[schemars(range(min = 1, max = 100))] + pub limit: Option<usize>, +} +#[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<String>, + 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<String>, + pub runtime_id: Option<String>, + pub worker_id: Option<String>, + /// 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<TicketAssignmentInputDiagnostic>, + pub next_input_event_id: Option<String>, + /// Assignment-scoped admission evidence; not attributed to individual input. + pub starts: Vec<TicketAssignmentStartDiagnostic>, + pub next_start_operation_id: Option<String>, +} + transparent_ticket_dto!(WorkspaceTicketTagListRequest, ticket::TagListRequest); transparent_ticket_dto!( WorkspaceTicketTagMutationRequest, diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index fe533c862..c154f950c 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -500,6 +500,48 @@ fn validate_ticket_browser_operations( 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", diff --git a/crates/ticket/src/assignment_answer.rs b/crates/ticket/src/assignment_answer.rs new file mode 100644 index 000000000..fb794f7a7 --- /dev/null +++ b/crates/ticket/src/assignment_answer.rs @@ -0,0 +1,117 @@ +//! Continuing input is authored against an observed delegation, never a new Assign. +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<TicketAssignmentAnswerReceipt> { + 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_tracking.rs b/crates/ticket/src/assignment_tracking.rs index 9aada331a..6a4853c09 100644 --- a/crates/ticket/src/assignment_tracking.rs +++ b/crates/ticket/src/assignment_tracking.rs @@ -898,3 +898,53 @@ pub(crate) fn verify_assignment_tracking_schema(conn: &Connection) -> Result<()> #[cfg(test)] #[path = "assignment_tracking_tests.rs"] mod tests; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketAssignmentsPage { + pub items: Vec<TicketAssignmentTrackingProjection>, + pub next_assignment_id: Option<String>, +} + +/// 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<TicketAssignmentsPage> { + 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::<std::result::Result<Vec<_>, _>>() + .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::<Result<Vec<_>>>()?; + Ok(TicketAssignmentsPage { + items, + next_assignment_id, + }) +} diff --git a/crates/ticket/src/assignment_tracking_tests.rs b/crates/ticket/src/assignment_tracking_tests.rs index ca66d3636..c5ea6d1c3 100644 --- a/crates/ticket/src/assignment_tracking_tests.rs +++ b/crates/ticket/src/assignment_tracking_tests.rs @@ -728,3 +728,104 @@ fn schema_verification_does_not_normalize_away_case_sensitive_authority_literals .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/lib.rs b/crates/ticket/src/lib.rs index d65d78b1a..21cd3a933 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -20,6 +20,8 @@ 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; 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::<serde_json::Value>(&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/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index a6e6e527a..bac5576b9 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -4,6 +4,7 @@ //! module binds an authority-scoped Workspace client, declares the built-in //! feature, and contributes those tools through the normal registry path. +mod assignment_public; mod assignment_tracking; mod tags; @@ -134,7 +135,14 @@ 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<ticket::TicketClosureReason>, + /// Exact current assigned principal; retained relationships do not match. + assigned_principal: Option<ticket::TicketAssignmentPrincipalSnapshot>, + /// Explicit self-reported handling, never inferred from Worker/MR/delivery. + handling_status: Option<ticket::TicketAssignmentTrackingStatus>, /// Full-text match over Ticket title, item body, and bounded thread excerpts. query: Option<String>, /// Exact case states (open or closed). Empty means every state. @@ -444,6 +452,9 @@ fn workspace_ticket_decision_definition( fn workspace_ticket_tool_description(name: &str) -> String { match name { "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() } @@ -586,6 +597,17 @@ impl TicketFeatureAccess { .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() } @@ -597,7 +619,6 @@ impl TicketFeatureAccess { || (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)) } } @@ -608,13 +629,13 @@ const READ_ONLY_TOOL_NAMES: &[&str] = &[ "WorkspaceTagList", "TicketAssignmentTrackingQuery", "TicketAssignmentTrackingHistory", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", ]; const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", "TicketEditItem", - "TicketMarkReady", - "TicketQueue", "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", @@ -622,8 +643,6 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[ const THREAD_TOOL_NAMES: &[&str] = &["TicketComment"]; -const INTAKE_TOOL_NAMES: &[&str] = &["TicketIntakeReady"]; - #[cfg(test)] const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketCreate", @@ -631,8 +650,6 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "QueryTicket", "ShowTicket", "TicketComment", - "TicketMarkReady", - "TicketQueue", "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", @@ -643,6 +660,11 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketTagsSet", "TicketAssignmentTrackingQuery", "TicketAssignmentTrackingHistory", + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", + "TicketAssignmentAnswer", ]; #[cfg(test)] @@ -664,6 +686,11 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "TicketAssignmentTrackingQuery", "TicketAssignmentTrackingHistory", "TicketAssignmentTrackingUpdate", + "TicketOrchestratorAssign", + "TicketOrchestratorClear", + "TicketAssignmentsQuery", + "TicketAssignmentDiagnostics", + "TicketAssignmentAnswer", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ @@ -1518,20 +1545,32 @@ fn native_ticket_operation(tool_name: &str) -> Option<NativeTicketOperation> { 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, @@ -1652,6 +1691,17 @@ pub(crate) fn enabled_ticket_definitions( 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 } @@ -2134,15 +2184,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); } @@ -2966,7 +3007,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( @@ -2983,22 +3024,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 ); } @@ -3168,7 +3208,7 @@ mod tests { .collect::<Vec<_>>(); assert!(workspace_tools.contains(&"TicketCreate")); assert!(workspace_tools.contains(&"TicketEditItem")); - assert!(workspace_tools.contains(&"TicketQueue")); + assert!(!workspace_tools.contains(&"TicketQueue")); assert!(workspace_tools.contains(&"TicketCaseUpdate")); let orchestration = workspace_feature(TicketFeatureAccess::workflow()); @@ -3205,6 +3245,57 @@ mod tests { assert!(!review_tools.contains(&"TicketCaseUpdate")); } + #[test] + fn normal_and_wip_catalogs_exclude_retired_ticket_workflow_operations() { + let client: Arc<dyn WorkspaceClient> = 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] fn read_only_installation_does_not_expose_mutating_tools() { let mut pending_tools = Vec::new(); @@ -3248,8 +3339,8 @@ 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 == "TicketCaseUpdate")); assert!( 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..283eeb8c8 --- /dev/null +++ b/crates/worker/src/feature/builtin/ticket/assignment_public.rs @@ -0,0 +1,340 @@ +//! Public delegation operations over shared host-bound typed Workspace forwarding. +//! Source/actor authorization and assignment/receipt authority remain Backend-owned. +#[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<String>, + /// 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<Option<String>, D::Error> { + Option::<String>::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<usize>) -> Result<usize, ToolError> { + 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<String>) -> Result<(), ToolError> { + value + .as_deref() + .map(|value| identity(name, value)) + .transpose() + .map(|_| ()) +} + +struct PublicAssignmentTool { + client: Arc<dyn WorkspaceClient>, + kind: PublicAssignmentKind, +} +#[async_trait] +impl Tool for PublicAssignmentTool { + async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result<ToolOutput, ToolError> { + 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::<String>() + ))); + } + 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::<server_api::TicketRoleAssignmentMutationResponse>( + &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::<server_api::TicketAssignmentAnswerResponse>(&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<dyn WorkspaceClient>, + 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<dyn Tool>, + ) + }) +} 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<Vec<WorkspaceRequest>>, + response: Result<WorkspaceResponse, String>, + 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<WorkspaceResponse, WorkspaceClientError> { + self.requests.lock().unwrap().push(request); + self.response.clone().map_err(WorkspaceClientError::Request) + } +} +fn transport(status: u16, body: Value) -> Arc<Transport> { + 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::<Value>(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 index 349d1aacf..21835d607 100644 --- a/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs +++ b/crates/worker/src/feature/builtin/ticket/assignment_tracking.rs @@ -74,7 +74,7 @@ pub(super) fn description(name: &str) -> &'static str { "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. 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." + "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"), } diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 4d2497661..89cac0c61 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -866,9 +866,10 @@ mod tests { .unwrap(); assert!(server.starts_with("Assigned Tickets have unreceived input:")); - // TUI's final operation model is a separate downstream scope; both - // renderers still consume the same bounded resource-key projection. - assert!(tui.starts_with("Queued Tickets require attention:")); + 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")); diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index 294cd98d1..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<agen::tool::ToolMeta>) { + 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<agen::tool::ToolMeta>) { 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()); @@ -471,6 +480,147 @@ mod tests { } } + #[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" + ); + } + } + #[test] fn sub_worker_capabilities_follow_the_registered_canonical_control_tools() { let names = [ diff --git a/crates/worker/src/worker.rs b/crates/worker/src/worker.rs index 67163155d..fc1b60b22 100644 --- a/crates/worker/src/worker.rs +++ b/crates/worker/src/worker.rs @@ -729,6 +729,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 { @@ -784,6 +806,45 @@ fn encode_workspace_path_segment(value: &str) -> String { encoded } +fn workspace_server_query_request<T: serde::Serialize>( + method: WorkspaceRequestMethod, + path: String, + query: &T, +) -> Result<WorkspaceRequest, WorkspaceClientError> { + 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, @@ -791,6 +852,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" ))), diff --git a/crates/workspace-server/src/assignment_delivery.rs b/crates/workspace-server/src/assignment_delivery.rs index db59d0f51..db7135aec 100644 --- a/crates/workspace-server/src/assignment_delivery.rs +++ b/crates/workspace-server/src/assignment_delivery.rs @@ -124,12 +124,14 @@ WITH eligible AS ( SELECT a.ticket_id,a.assignment_id,'ticket-event:' || e.event_index,a.tracking_json,e.event_index FROM eligible a JOIN typed_ticket_events e ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id WHERE e.event_index>a.after_ticket_event_index - -- External content/answer inputs, not case/routing/history or own self-report. - -- Ticket supports typed extension events; an answer need not be named comment. - AND e.kind NOT IN ('create','close','state_changed','status_changed','history') + -- 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 @@ -378,3 +380,85 @@ pub(super) fn save_operation_fence( } 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<server_api::TicketAssignmentDiagnosticsPage> { + 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<String> = 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::<std::result::Result<Vec<_>,_>>()?; + 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::<std::result::Result<Vec<_>,_>>()?; + 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 index 7a93d3c12..b8702edb7 100644 --- a/crates/workspace-server/src/assignment_delivery_tests.rs +++ b/crates/workspace-server/src/assignment_delivery_tests.rs @@ -11,6 +11,27 @@ fn delivery_store_external_event( }).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, @@ -60,11 +81,18 @@ async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_a 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:2"); + assert_eq!(pending[0].input_event_id, "ticket-event:3"); assert_eq!( pending[0].waiting_reason.as_deref(), Some("Waiting for owner answer") @@ -74,7 +102,7 @@ async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_a Some("Read answer and decide") ); let answer = store - .reserve_ticket_assignment_delivery("w", "a", "ticket-event:2", &source) + .reserve_ticket_assignment_delivery("w", "a", "ticket-event:3", &source) .unwrap(); store .ack_ticket_assignment_delivery("w", &answer.delivery_id, &source) @@ -85,16 +113,16 @@ async fn assignment_delivery_waiting_self_reports_do_not_busy_loop_and_answers_a .unwrap() .is_empty() ); - // Typed extension inputs must not disappear merely because they are not - // comments; own tracking/history events remain excluded. - delivery_store_external_event(&store, 3, "assignment_tracking_updated", None); - delivery_store_external_event(&store, 4, "history", None); - delivery_store_external_event(&store, 5, "owner_answer", None); + // 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:5"); + assert_eq!(pending[0].input_event_id, "ticket-event:6"); let saved = store .get_ticket_assignment_tracking("w", "tracking", "a") .unwrap() @@ -792,3 +820,216 @@ async fn assignment_startup_unfinished_dependencies_remain_diagnostics_not_store }) .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 index c798df247..f1cfa808e 100644 --- a/crates/workspace-server/src/assignment_tracking.rs +++ b/crates/workspace-server/src/assignment_tracking.rs @@ -171,3 +171,45 @@ pub(super) fn history_page( }) }) } + +pub(super) fn assignments_page( + store: &SqliteWorkspaceStore, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, +) -> Result<ticket::TicketAssignmentsPage> { + 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<ticket::TicketAssignmentAnswerReceipt> { + 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 index 7c85c4812..513daef26 100644 --- a/crates/workspace-server/src/assignment_tracking_tests.rs +++ b/crates/workspace-server/src/assignment_tracking_tests.rs @@ -458,7 +458,10 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip vec![ (91, "workspace schema baseline".into()), (92, "Ticket-owned assignment tracking".into()), - (93, "Durable assignment deliveries and source-bound startup fences".into()) + ( + 93, + "Durable assignment deliveries and source-bound startup fences".into() + ) ] ); for (table, rows) in retained.iter().zip(original.iter()) { @@ -1485,3 +1488,337 @@ async fn orchestrator_clear_receipt_compares_nullable_reason_as_full_payload_not .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/authority.rs b/crates/workspace-server/src/authority.rs index abda3e7b2..1203f2503 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -405,6 +405,54 @@ impl SqliteWorkspaceAuthority { .collect::<Vec<_>>(); 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)")); @@ -426,7 +474,9 @@ impl SqliteWorkspaceAuthority { "(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 )); } @@ -1121,6 +1171,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 { @@ -1134,6 +1185,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { &detail, authoritative_body.as_str(), &authoritative_events, + &authoritative_case_history, &query, ); let item = ticket_query_item( @@ -1141,6 +1193,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)); @@ -1993,6 +2046,33 @@ fn ticket_evidence_summary( } fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { + 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( @@ -2181,11 +2261,27 @@ fn ticket_attention_matches( } } +fn closure_reason_text(action: &ticket::TicketCaseAction) -> Option<String> { + 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 { let has_tag = |id: &String| detail.tags.iter().any(|tag| &tag.id == id); @@ -2311,7 +2407,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() @@ -2324,6 +2423,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(); @@ -2331,6 +2431,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()); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 5e5742bb5..5ed22bcc8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3097,7 +3097,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 { @@ -3107,7 +3109,7 @@ async fn serve_server_static_shell(api: &WorkspaceServerApi, path: &str) -> Resp static_file_or_spa_response(static_root, path).await } -// Canonical case/tracking routes are deliberately not /api/w aliases. Recognize +// 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('/'); @@ -3119,10 +3121,10 @@ fn canonical_ticket_operation_workspace_id(path: &str) -> Option<&str> { && resource == "tickets" && !ticket_id.is_empty() && match operation { - "case" => segments.next().is_none(), + "case" | "assignment-diagnostics" | "assignment-answer" => segments.next().is_none(), "assignment-tracking" => match segments.next() { None => true, - Some("history") => segments.next().is_none(), + Some("history" | "assignments") => segments.next().is_none(), _ => false, }, _ => false, @@ -6614,6 +6616,18 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::ticket_case_update( service.clone(), )) + .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(), )) @@ -10566,6 +10580,132 @@ 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<server_api::TicketAssignmentsPage, server_api::RepositoryApiError> + { + let api = self.workspace_api()?; + 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) + } + async fn ticket_assignments_page_browser( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + ticket_id: String, + query: server_api::TicketAssignmentsQuery, + ) -> std::result::Result<server_api::TicketAssignmentsPage, server_api::RepositoryApiError> + { + 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()?; + 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) + } + 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()?; + 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)) + } + 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, @@ -48195,6 +48335,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(); @@ -48350,8 +48526,15 @@ 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(); - assert_eq!(inputs.len(), 4, "Assign input plus three external events"); + assert_eq!(inputs.len(), 1, "one fenced continuing answer"); let unique = inputs .iter() .map(|(_, content)| content.clone()) @@ -50357,11 +50540,13 @@ 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(), 2, - "initial delegation plus external comment" + "initial delegation plus fenced answer; ordinary comment is history" ); assert_eq!( notifications[0].0.worker_id.to_string(), @@ -50406,6 +50591,7 @@ mod tests { 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()) diff --git a/crates/workspace-server/src/server_assignment_attention_tests.rs b/crates/workspace-server/src/server_assignment_attention_tests.rs index d72a8e735..7f8c57d56 100644 --- a/crates/workspace-server/src/server_assignment_attention_tests.rs +++ b/crates/workspace-server/src/server_assignment_attention_tests.rs @@ -309,7 +309,11 @@ async fn orchestrator_attention_unknown_response_retry_keeps_id_and_payload() { "existing" ); let retry = execution.take_inputs(); - assert_eq!(retry.len(), 2, "acknowledged retry must also drain the changed backlog"); + 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(); @@ -760,11 +764,27 @@ async fn real_attention_worker_start_restore(lose_response: bool) { assert_eq!(receipts().len(), 1); assert_eq!(commits(), 1); // Only a new external input earns another delivery, not restore itself. - browser_ticket_backend(&api) + 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() - .add_event( - ticket.id.clone().into(), - ticket::NewTicketEvent::new(ticket::TicketEventKind::Comment, "New owner answer"), + .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); diff --git a/crates/workspace-server/src/server_assignment_delivery_tests.rs b/crates/workspace-server/src/server_assignment_delivery_tests.rs index 489674c19..09a50e532 100644 --- a/crates/workspace-server/src/server_assignment_delivery_tests.rs +++ b/crates/workspace-server/src/server_assignment_delivery_tests.rs @@ -163,6 +163,41 @@ async fn delivery_self_report( 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() @@ -212,7 +247,18 @@ async fn assignment_waiting_self_report_does_not_loop_but_external_answer_is_del ); let waiting = delivery_tracking(&api, &ticket.id, assignment); assert_eq!(serde_json::to_value(&waiting).unwrap(), reported); - delivery_external_comment(&api, &ticket.id, "Human answer: choose option B"); + 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(), @@ -369,7 +415,10 @@ async fn assignment_clear_and_reassign_discard_old_unknown_delivery_without_stop 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_ne!( + ids[0], old_ids[0], + "new assignment reused stale input identity" + ); assert_eq!( delivery_assign(&api, &ticket.id, "assign-old").await, assigned @@ -404,7 +453,8 @@ async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_a &ticket.id, assigned["assignment"]["assignment_id"].as_str().unwrap(), ); - delivery_self_report(&api, &identity, &worker, ¤t, "waiting", "wait-answer").await; + delivery_self_report(&api, &identity, &worker, ¤t, "waiting", "wait-answer") + .await; execution.take_inputs(); execution.take_input_request_ids(); } @@ -422,29 +472,33 @@ async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_a true, |state, pending| { check_orchestrator_assignment_attention( - &caller_api, &caller_worker, state, pending, + &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(); + 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(); + entered_rx + .recv_timeout(std::time::Duration::from_secs(5)) + .unwrap(); if answer { - futures::executor::block_on(delivery_http( + futures::executor::block_on(delivery_external_answer( &api, - "POST", - &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/thread-events", ticket.id), - Some(serde_json::to_value(NewTicketEvent::new( - TicketEventKind::Comment, "Human answer: go ahead", - )).unwrap()), - StatusCode::NO_CONTENT, + &ticket.id, + "Human answer: go ahead", + "racing-answer", )); } else { futures::executor::block_on(delivery_assign(&api, &ticket.id, "racing-assign")); @@ -456,7 +510,11 @@ async fn assignment_or_answer_committed_during_empty_scan_is_delivered_without_a }); 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())); + assert!( + inputs[0] + .1 + .contains(ticket.resource_key.as_deref().unwrap()) + ); if answer { assert!(inputs[0].1.contains("Need the human's choice")); } @@ -476,15 +534,26 @@ async fn assignment_failed_delivery_and_its_reentrant_idle_do_not_schedule_retry 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, + &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"); + 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"); + assert_eq!( + attention_pending(&api).len(), + 1, + "failure must retain durable input" + ); delivery_stop(&api, &worker); } @@ -796,8 +865,22 @@ async fn delivery_wait_idle( "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() @@ -962,16 +1045,12 @@ async fn assignment_real_worker_response_loss_restore_and_concurrent_scans_commi waiting ); let before = execution.attempts.lock().unwrap().len(); - browser_ticket_backend(&reopened) - .unwrap() - .add_event( - ticket.id.clone().into(), - ticket::NewTicketEvent::new( - ticket::TicketEventKind::Comment, - "External answer after server restart", - ), - ) - .unwrap(); + 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) diff --git a/crates/workspace-server/src/server_assignment_tracking_tests.rs b/crates/workspace-server/src/server_assignment_tracking_tests.rs index 7ad464549..5c315a5d1 100644 --- a/crates/workspace-server/src/server_assignment_tracking_tests.rs +++ b/crates/workspace-server/src/server_assignment_tracking_tests.rs @@ -68,9 +68,13 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ })).unwrap(); tracking_response( app.clone(), - Request::builder().method("PUT").uri(&worker_path).header(CONTENT_TYPE, "application/json") + Request::builder() + .method("PUT") + .uri(&worker_path) + .header(CONTENT_TYPE, "application/json") .header("authorization", format!("Bearer {token}")) - .body(Body::from(worker_input)).unwrap(), + .body(Body::from(worker_input)) + .unwrap(), StatusCode::OK, ) .await; @@ -127,7 +131,10 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ StatusCode::OK, ) .await; - assert_ne!(fresh["assignment"]["assignment_id"].as_str(), Some(assignment)); + assert_ne!( + fresh["assignment"]["assignment_id"].as_str(), + Some(assignment) + ); assert_eq!( api.store .get_ticket_assignment_tracking(TEST_WORKSPACE_ID, &ticket, assignment) @@ -146,9 +153,18 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_ .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()); + 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() + ); } } @@ -1494,3 +1510,550 @@ async fn assignment_signed_runtime_without_worker_cannot_self_assign_service_rol .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::<Vec<_>>(); + 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/store.rs b/crates/workspace-server/src/store.rs index a709fa672..3e44c9081 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -1889,6 +1889,30 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { ) -> Result<Option<TicketRoleAssignmentRecord>>; /// 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<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>; fn update_ticket_assignment_tracking( &self, workspace_id: &str, @@ -7872,6 +7896,50 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn page_ticket_assignments( + &self, + workspace_id: &str, + ticket_id: &str, + after_assignment_id: Option<&str>, + limit: usize, + ) -> Result<ticket::TicketAssignmentsPage> { + 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<server_api::TicketAssignmentDiagnosticsPage> { + 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<ticket::TicketAssignmentAnswerReceipt> { + assignment_tracking::answer(self, workspace_id, ticket_id, input, source, actor) + } fn update_ticket_assignment_tracking( &self, workspace_id: &str, diff --git a/docs/design/flow-state-graph.md b/docs/design/flow-state-graph.md index ffada9be8..5d348542c 100644 --- a/docs/design/flow-state-graph.md +++ b/docs/design/flow-state-graph.md @@ -81,7 +81,7 @@ Runtime accepts exactly one Flow segment only when the resolved Profile enables The generic model-facing `WorkerSpawn` accepts `initial_submit: Vec<Segment>` and routes them unchanged through the shared Workspace spawn request into Runtime `CreateWorkerRequest.initial_input`. It does not have a parallel `initial_text` or a role-specific `SpawnCoder` wrapper. Backend derives the flat content projection from the canonical segment vector, validates Flow shape before spawn, and includes the segment vector in lifecycle idempotency fingerprints. Runtime does not commit Worker creation or report spawn success merely because the initial Submit request entered the Worker's in-memory channel: Runtime assigns the Submit an opaque id, the Worker commits that id as an extension on the same `UserInput` entry as any initial `FlowRuntimeState`, and the execution backend must return a matching typed input-commit acknowledgement. Restoring the same Worker never replays spawn initial segments. -When an Orchestrator supplies `ticket_id` to generic `WorkerSpawn`, the Worker tool derives the assignment operation id from the durable tool-call id rather than accepting lifecycle authority from model input. The shared Workspace worker-create route projects that request into a Coder Ticket-role intent and atomically applies the existing queued-Ticket assignment operation only after Runtime has returned the input-commit acknowledgement. A spawn or pre-commit input failure therefore leaves the Ticket queued and unassigned. +When an Orchestrator supplies `ticket_id` to generic `WorkerSpawn`, it must also supply its observed current `orchestrator_assignment_id`. The tool derives the start operation identity from durable tool-call context, not model role text. The shared guarded Worker-create path accepts the selected Profile/optional Flow and binds Worker assignment plus resource finalization only after Runtime initial-input commit acknowledgement. Assign is delegation without ready/Queue; unresolved dependencies are diagnostics, not a blanket start gate. Backend fences the exact current delegation and authenticated source across reserve/bind/finalize/recovery. A failed or unknown external outcome must be reconciled from durable operation evidence rather than silently creating a replacement. Start does not update Ticket open/closed or Orchestrator self-reported waiting/inprogress/done. See [delivery/start fences](../development/ticket-assignment-delivery.md). `RequestFlowTransition` accepts only: diff --git a/docs/design/merge-request-wip-projection.md b/docs/design/merge-request-wip-projection.md index 14ddee2d8..d109926d6 100644 --- a/docs/design/merge-request-wip-projection.md +++ b/docs/design/merge-request-wip-projection.md @@ -40,7 +40,7 @@ Typical role surfaces are therefore: - Reviewer: item `read` and `review`. - Orchestrator: item `read`, `check_readiness`, and `complete`. -`CompleteTicket` belongs to the Ticket Feature and route-bound Ticket item `complete`, alongside state and close decisions. It takes current item/state CAS, a replay key, reason, and optional references; it requires neither an MR result set nor a Reviewer approval. The former MR collection `complete_ticket` operation is not published. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run. +`TicketCaseUpdate` belongs to the Ticket Feature and route-bound Ticket item `case_update`, not this MR provider. Close/reopen/correction judgments take current item/state/nullable closure-key CAS, an exact-retry key, typed reason/explanation and optional references; generic closure requires neither an MR result set nor Reviewer approval. Assignment handling done is an independent self-report and does not close the case, integrate an MR, release actual work or stop a Worker. Explicit user approval conditions remain judgment requirements; MR integration still uses its guarded review path. Historical `CompleteTicket` and former MR collection `complete_ticket` operations are not published. The review descriptor never contains the injected capability token, and a review item route must exactly match the Merge Request captured by that capability before the ordinary review Tool can run. ## Preserved review and integration gates diff --git a/docs/design/orchestrator-worktree-layout.md b/docs/design/orchestrator-worktree-layout.md index ce6f533a4..c14d08539 100644 --- a/docs/design/orchestrator-worktree-layout.md +++ b/docs/design/orchestrator-worktree-layout.md @@ -1,5 +1,7 @@ # Orchestrator worktree and implementation worktree layout +> Historical layout decision and branch report. The Queue/state and mandatory merge→close→cleanup policy below is superseded by [current work operations](../development/work-items.md). Current delegation starts at Assign without ready/Queue; open/closed case judgments, self-reported assignment handling, guarded MR integration, actual work release and Worker/resource cleanup are independent. Respect the requested return path. The original report is preserved below; explicit attached Workdirs and Backend resource authority, not inferred roots, govern current execution. + Yoi can run an Orchestrator from a dedicated orchestration worktree while implementation worktrees still belong under the original workspace root. The runtime must therefore distinguish role execution cwd/workspace from the target repository root used for implementation branches and merge-completion. This decision records the layout policy for Ticket `00001KTTB479X`. diff --git a/docs/design/ticket-evidence.md b/docs/design/ticket-evidence.md index 122399440..637a4b43a 100644 --- a/docs/design/ticket-evidence.md +++ b/docs/design/ticket-evidence.md @@ -1,6 +1,6 @@ # Ticket evidence and immutable Merge Request results -Ticket lifecycle, repository integration, and current requirement attestation are separate facts. An incomplete read-side evidence summary does **not** undo a recorded `done` state or a `CompleteTicket` event. +Ticket open/closed case state, assignment self-reported waiting/inprogress/done, Worker lifecycle, repository integration, and current requirement attestation are separate facts. An incomplete read-side evidence summary does **not** undo a recorded case judgment or handling result. Historical `done` states and `CompleteTicket` events remain readable as history, not current case states or operations. ## Evidence subjects @@ -16,9 +16,9 @@ A merged MR's review status/timestamps describe **integration approval**, not a `approved_current_subject` describes valid source/integration approvals. `review_after_rescope` retains its wire name but now describes exact current requirement attestation, not ordering of wall-clock timestamps. `approved_review` query filtering requires that current attestation. `stale_after_rescope` requires substantive item-edit history and a known source-approved snapshot lacking current attestation; unknown observations are only `missing_evidence`. -`complete_for_integration` means a nonempty linked MR set has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. An MRless Ticket has no applicable MR evidence: `missing` is empty, `review_status` is null, and approval/integration flags remain false. It does not match `missing_evidence` or `approved_review` merely because its state is done. +`complete_for_integration` means a nonempty linked MR set has valid source approvals and current requirement evidence, including for partially or fully integrated sets. It is not a claim that open repositories have been integrated, that target integration readiness has been checked, or that Ticket completion is authorized. An MRless Ticket has no applicable MR evidence: `missing` is empty, `review_status` is null, and approval/integration flags remain false. It does not match `missing_evidence` or `approved_review` merely because the case is closed or handling is done. -CompleteTicket belongs to the Ticket domain. It records the authenticated actor's reason/result and optional references using current item `content_digest`/state CAS and an operation receipt. It needs no MR or Reviewer approval, and does not create or modify those proofs. State setting, completion, close, and reopen share the same transactional update boundary. Exact replay returns current Ticket authority without reapplying a historical decision or appending an event. Historical MR-based `ticket_completion` records remain readable and distinct from new judgment receipts. +`TicketCaseUpdate` records the authenticated actor's close/reopen/correction judgment, typed reason, explanation and optional references using current item `content_digest`, open/closed state and explicitly observed nullable closure operation key CAS, plus an exact-replay operation key. It needs no MR or Reviewer approval and creates neither proof. Explicit user approval conditions still govern judgment; guarded MR integration still requires trusted review. Exact replay never reapplies a historical decision or appends another event; reread current authority before a new operation. Historical `CompleteTicket` and MR-based `ticket_completion` records remain readable and distinct from current case receipts. Handling done is an independent self-report, not closure, Backend certification of fulfillment, MR approval/integration, work release or Worker termination. Classification tag edits do not alter a case judgment. Writable targets authorize deliverables, not mandatory MRs for every target. Every linked MR remains part of the exact requirement snapshot; no arbitrary latest-review or single-repository shortcut is used. diff --git a/docs/design/ticket-wip-projection.md b/docs/design/ticket-wip-projection.md index 146e1e193..4d6a2da88 100644 --- a/docs/design/ticket-wip-projection.md +++ b/docs/design/ticket-wip-projection.md @@ -26,7 +26,11 @@ Collection interface `yoi.ticket/collection/v1`: | Native operation | Existing Tool identity | Subject identity | | --- | --- | --- | | `query` | `QueryTicket` | Collection; preserves bounded filters, limit and cursor | -| `create` | `TicketCreate` | Collection; Backend assigns identity | +| `create` | `TicketCreate` | Collection; Backend assigns identity to an open case | +| `tag_list` | `WorkspaceTagList` | Workspace classification; bounded stable-ID pagination | +| `tag_create` | `WorkspaceTagCreate` | Display name and exact-retry operation key | +| `tag_rename` | `WorkspaceTagRename` | Stable tag ID, observed `expected_last_operation_key`, name and operation key | +| `tag_delete` | `WorkspaceTagDelete` | Stable tag ID, observed `expected_last_operation_key` and operation key | Item interface `yoi.ticket/item/v1`: @@ -35,11 +39,16 @@ Item interface `yoi.ticket/item/v1`: | `read` | `ShowTicket` | `event_limit`, `event_cursor` | | `edit` | `TicketEditItem` | title/body replacement and targets edit | | `comment` | `TicketComment` | comment body | -| `mark_ready` | `TicketMarkReady` | optional reason | -| `intake_ready` | `TicketIntakeReady` | intake summary and optional reason | -| `queue` | `TicketQueue` | none | -| `transition` | `TicketWorkflowState` | expected/target state, reason and body | -| `close` | `TicketClose` | resolution | +| `case_update` | `TicketCaseUpdate` | typed close/reopen/correction action, explanation, item/state/nullable closure-key CAS and exact-retry key | +| `assignment_tracking_query` | `TicketAssignmentTrackingQuery` | optional retained `assignment_id` | +| `assignment_tracking_history` | `TicketAssignmentTrackingHistory` | `assignment_id`, bounded limit and event cursor | +| `assignment_tracking_update` | `TicketAssignmentTrackingUpdate` | current `assignment_id`, observed `expected_last_event_id`, operation key and complete self-reported handling | +| `orchestrator_assign` | `TicketOrchestratorAssign` | target principal, explicitly observed nullable `expected_assignment_id`, stable `operation_id`; explicit reissue uses a new operation, exact retry keeps the original | +| `orchestrator_clear` | `TicketOrchestratorClear` | observed `assignment_id`, stable `operation_id` | +| `assignments_query` | `TicketAssignmentsQuery` | bounded limit and `after_assignment_id` cursor for retained rows | +| `assignment_diagnostics` | `TicketAssignmentDiagnostics` | `assignment_id`, bounded limit, independent `after_input_event_id` / `after_start_operation_id` cursors | +| `assignment_answer` | `TicketAssignmentAnswer` | observed same `assignment_id` / `expected_last_event_id`, stable `operation_key`, bounded answer body | +| `tags_set` | `TicketTagsSet` | stable desired `tag_ids`, observed full `expected_tag_ids` set and operation key | | `dependency_check` | `TicketDependencyCheck` | none | | `record_relation` | `TicketRelationRecord` | relation kind, **target Ticket**, optional note | | `remove_relation` | `TicketRelationRemove` | relation kind and **target Ticket** | @@ -50,11 +59,12 @@ The subject `ticket`/`id` parameter is removed from every item descriptor and re Access profiles therefore expose different native interfaces: -- read-only/review: collection `query`; item `read`; -- workspace authoring: query/create plus read/edit/comment/ready/queue/close/relation operations; -- intake: the authoring surface plus `intake_ready`; -- workflow/orchestration: query/read/comment, workflow transition/close/dependency checks, relation operations and orchestration-plan operations; -- work-report: query/read/comment only. +- read-only/review: collection query/tag list; item read, assignment tracking query/history, retained assignments and sanitized diagnostics; +- workspace authoring/intake: query/create, read/edit/comment/case judgment, relation/classification operations, tracking reads, explicit Orchestrator Assign/reissue/clear and continuing answer; +- workflow/orchestration: query/read/comment/case judgment, dependency diagnostics, relation/plan/classification operations, tracking reads/self-report update, explicit Assign/reissue/clear and continuing answer; +- work-report: query/read/comment/case judgment/classification, assignment reads/diagnostics and continuing answer. Actual exposure is additive from manifest flags; authoring-enabled Coder configurations also expose delegation, subject to Backend authorization. + +There are no current native ready/intake-ready/queue/six-state transition operations. Assign is delegation without a separate Queue; Worker starts are separately guarded and require the current observed `orchestrator_assignment_id` for Orchestrator calls. Assignment public operations call shared typed `WorkspaceClient::execute_server_operation` forwarding with actual Server DTOs; no direct HTTP/storage fallback or second ledger is introduced. Role assignment tools target Orchestrator responsibility only, not Worker execution/lifecycle; target principal is never the actor. Query first, preserve exact operation/input on retry, and reread after conflict/unknown outcome rather than automatically rekeying. Missing host support is a concrete authority error, never an alternate transport. No native operation is inferred for a Tool that the Feature did not register. Enabled Ticket tools claimed by the native projection are removed from `/tools`; normal Tool mode is unchanged. @@ -64,7 +74,7 @@ No native operation is inferred for a Tool that the Feature did not register. En Backend 401/403 failures map to permission denial, 404 to missing objects, and deterministic 400/409/422 validation or workflow failures to invalid arguments when the typed operation fails before any successful mutation. Cancellation and interruption remain distinct. Post-mutation canonical-reference lookups are explicitly marked as unknown-outcome stages, so even a later deterministic HTTP status cannot misreport an already-committed write as a rejection. Transport, server, malformed response, and other post-dispatch failures retain unknown outcome, and mutating operations are not retried automatically. -Successful reads observe the authoritative item `content_digest`. Successful mutations advance a local observation generation even when the first call uses an internal ID whose canonical alias has not yet been learned. Once a read or mutation reveals the canonical key, the canonical/internal alias groups are merged and invalidated together. Relation mutations also invalidate the reported target Ticket, while queue results invalidate every reported queued Ticket, because those objects' incoming relations or workflow states changed. A stale validator is rejected by the common WIP Host before another Backend dispatch; the caller must rediscover/reinspect and explicitly decide whether to call again. +Successful reads observe the authoritative item `content_digest`. Successful mutations advance a local observation generation even when the first call uses an internal ID whose canonical alias has not yet been learned. Once a read or mutation reveals the canonical key, the canonical/internal alias groups are merged and invalidated together. Relation mutations also invalidate the reported target Ticket because its inverse relation view changed. A stale validator is rejected by the common WIP Host before another Backend dispatch; the caller must rediscover/reinspect and explicitly decide whether to call again. ## Preserved Ticket contracts @@ -72,11 +82,12 @@ The projection invokes the existing Tool and scoped Backend routes, so the follo - item content digest and audited item/thread/state events; - title/body/targets input validation; -- planning-to-ready target validation and write-target requirement; -- queue dependency traversal, planning-dependency rejection and cycle detection; -- expected-state workflow transitions and close semantics; +- open/closed case judgments with item/state/closure-key CAS, typed reason and immutable close/correction/reopen history; +- stable classification tag IDs, tag operation CAS, full tag-set comparison and exact-retry receipts; +- immutable assignment identity and self-reported waiting/inprogress/done, observed event CAS and complete-payload exact replay; +- dependencies as diagnostics rather than ready/Queue or blanket launch gates; - canonical `T-*`/internal-ID resolution and Workspace isolation; - exact forward relation ownership, inverse views and relation target validation; - orchestration-plan validation and bounded query behavior. -The ordinary JSON Schema is retained as a second validation boundary behind the WIP descriptor. Optional fields omitted by the caller stay omitted, while an explicitly supplied `null` stays present as JSON `null`, so permission matching sees the same input shape as normal Tool mode. Defaults are applied only by the same ordinary typed Tool that applies them in Tool mode. +The ordinary JSON Schema is retained as a second validation boundary behind the WIP descriptor. Handling is never inferred from delivery, Worker or MR state. Waiting answers continue the same assignment; explicit done reissue creates a new row and preserves the prior result. Done does not close the case, release work, stop a Worker or require unassignment. Generic reasoned closure has no universal MR/human-approval gate; explicit user conditions and guarded MR integration remain separate. Exact retry retains the same operation key and full input; a receipt is not a fresh observation. On stale/current-assignment or CAS conflicts reread and reconcile; unknown outcomes must not be silently retried as a fresh operation. Optional fields omitted by the caller stay omitted, while an explicitly supplied `null` stays present as JSON `null`, so permission matching sees the same input shape as normal Tool mode. Defaults are applied only by the same ordinary typed Tool that applies them in Tool mode. diff --git a/docs/design/workspace-kanban-orchestrator-runtime.md b/docs/design/workspace-kanban-orchestrator-runtime.md index 80ee7a4fc..42b30dd3e 100644 --- a/docs/design/workspace-kanban-orchestrator-runtime.md +++ b/docs/design/workspace-kanban-orchestrator-runtime.md @@ -1,5 +1,7 @@ # Workspace Kanban to backend Orchestrator runtime +> Historical design, not current operating instructions. O-19 / T-739–T-743 supersede the state/Queue routing and dependency start gates below. Current Ticket cases are open/closed; Assign starts delegation and initial waiting without ready/Queue, handling is an Orchestrator self-report, and MR integration remains independently guarded. See [current work operations](../development/work-items.md), [assignment tracking](../development/ticket-assignment-tracking.md), and [delivery/start fences](../development/ticket-assignment-delivery.md). The original proposal below is preserved, not rewritten as implementation history. + Workspace Kanban operations are control-plane requests. They may change Ticket state and request orchestration, but they must not directly execute shell, git, filesystem work, or send authority-bearing messages to raw local Worker sockets. The durable boundary is an orchestration event consumed by a backend-internal Orchestrator Worker. This document records the design boundary for connecting Kanban operations, Tickets, the Workspace backend, `WorkerRuntimeRegistry`, and filesystem-capable Workers. It is intentionally a planning artifact: it does not require the Workspace backend to implement every table, API, remote protocol, or spawn adapter immediately. diff --git a/docs/development/ticket-assignment-delivery.md b/docs/development/ticket-assignment-delivery.md index 59fa87214..e6b6a51a5 100644 --- a/docs/development/ticket-assignment-delivery.md +++ b/docs/development/ticket-assignment-delivery.md @@ -45,7 +45,11 @@ Unfinished dependencies remain query/relation diagnostics and do not disable man Action projection permits delegation independently of existing Worker occupancy; actual Worker start eligibility still excludes unfinished Worker work and closed cases. `can_start_manual_worker` no longer includes an unresolved-dependency predicate. `can_assign_orchestrator` permits an explicit fresh Assign/reissue operation even when a current row exists; callers can supply the observed expected assignment for CAS. `can_unassign_orchestrator` uses the retained current pointer, not active-work or case-state filtering. These authoring actions are not Worker transfer. Final browser controls must use these Backend decisions rather than recreating old phase/dependency gates. -Delivery diagnostics are available through the typed Store scan/reservation/ack records and structured `ticket_assignment_delivery_pending` warning (`assignment_id`, `input_event_id`, `delivery_id`, recipient and error category, never content). Existing `ticket_notification_delivery_failed` remains for recipient failures. No browser acknowledgement/status-edit endpoint is introduced: successful delivery is not a user handling update. T-743 can consume the operational query independently of handling without inventing a third status in tracking. +Delivery diagnostics are available through the typed Store scan/reservation/ack records and structured `ticket_assignment_delivery_pending` warning (`assignment_id`, `input_event_id`, `delivery_id`, recipient and error category, never content). Existing `ticket_notification_delivery_failed` remains for recipient failures. No browser acknowledgement/status-edit endpoint is introduced: successful delivery is not a user handling update. At the T-743 base `56795fb9`, `openapi/server-api.json` exposes assignment tracking and start fences but no assignment delivery query/receipt endpoint. The Store scan is an internal Rust API, not an authenticated browser/normal Tool/WIP route. Consumers must report this concrete API insufficiency for the owning Server/API handoff, not claim they can query delivery or infer it from handling/Worker state. Saved Assign plus delivery failure remains a saved delegation, never a reason to reassign. Any future diagnostics route must keep delivery, acceptance and actual start evidence separate from self-reported handling, and preserve bounded Workspace/assignment-scoped read authority. + +## T-743 public handoff following the base API gap + +The accepted T-743 public API adds canonical/browser `assignment-diagnostics`, `assignment-tracking/assignments` and fenced `assignment-answer` routes over the existing assignment/origin/delivery/start/receipt authority. The corresponding normal Tools/native WIP use shared typed Workspace forwarding. Diagnostics takes assignment identity and independent input/start cursors, bounds each page, and retains unknown/history distinctions; it does not expose saved rendered content or binding credentials, attest actual model processing, or mutate handling. Continuing answer atomically fences authenticated source/current assignment/observed event and saves the input/receipt, then triggers committed-input notification; it creates neither a new row nor automatic inprogress. These definitions supersede the base's unavailable public read/input surfaces, not the underlying delivery ledger or T-748 transaction ownership. See [assignment tracking](ticket-assignment-tracking.md) for ordinary Tools/WIP names and CAS/retry contracts. Combined route/generated/runtime validation is separately required; source definitions alone do not prove environment rollout. ## DB caller/transaction handoff for T-748 and protocol boundaries diff --git a/docs/development/ticket-assignment-tracking.md b/docs/development/ticket-assignment-tracking.md index fe71acde3..d9453aed4 100644 --- a/docs/development/ticket-assignment-tracking.md +++ b/docs/development/ticket-assignment-tracking.md @@ -30,7 +30,9 @@ Canonical Workspace endpoints: - `POST` on the same path: self-report update with event CAS and exact retry key. - `GET .../assignment-tracking/history`: assignment-scoped handling audit, with `assignment_id`, optional `after_event_id`, and `limit` (1–100). The returned `next_event_id` continues that history. -Normal tools and route-bound WIP operations expose `TicketAssignmentTrackingQuery`, `TicketAssignmentTrackingHistory`, and `TicketAssignmentTrackingUpdate`. Ticket query/detail and model projections include current assignment tracking. Read access does not grant mutation authority; Backend checks every update. +Normal tools and route-bound WIP operations expose `TicketAssignmentTrackingQuery`, `TicketAssignmentTrackingHistory`, and `TicketAssignmentTrackingUpdate`. T-743's public handoff adds `TicketAssignmentsQuery` for bounded retained Orchestrator rows (`GET .../assignment-tracking/assignments`, `after_assignment_id`, limit 1–100, `next_assignment_id`), `TicketAssignmentDiagnostics` for sanitized independent delivery/start pages (`GET .../assignment-diagnostics`, assignment ID, separate input/start cursors), and `TicketAssignmentAnswer` (`POST .../assignment-answer`). Answer uses the existing authenticated operation receipts with assignment/event CAS and bounded body; it continues waiting/inprogress on the same row and does not change handling. Done requires explicit reissue instead. + +`TicketOrchestratorAssign` / `TicketOrchestratorClear` use the existing role-assignment routes with a target principal, observed assignment identity and stable `operation_id`; explicit nullable `expected_assignment_id` is mandatory in Assign Tool/WIP input. These tools target responsibility, not Worker execution/lifecycle, and never derive the actor from model role text. Query first; retry exact full input with the original identity, never automatically generate a fresh key on stale/unknown outcome. Typed forwarding is shared `WorkspaceClient::execute_server_operation`; it does not introduce another ledger or direct HTTP/storage fallback. Read-only/review access includes retained rows and diagnostics, not Assign/clear/answer/self-report mutation. Ticket query/detail and model projections include current assignment tracking; `QueryTicket` also exposes exact current `assigned_principal` and self-reported `handling_status` filters. Read access does not grant mutation authority; Backend checks every update. Read a fresh projection before a new update. Do not treat a replayed receipt's last-event identity as the latest observed value. Retained-assignment query exposes Assign attribution and release/replacement independently; the history endpoint pages initial/migrated handling and subsequent self-reports, not delivery acknowledgements. diff --git a/docs/development/work-items.md b/docs/development/work-items.md index 6e4c4d5df..d39a5fb0d 100644 --- a/docs/development/work-items.md +++ b/docs/development/work-items.md @@ -6,7 +6,9 @@ Workspace Server control-plane records, append-only Ticket events, and any linke ## Concepts -- `Ticket`: durable work record containing intent, requirements, decisions, useful results/evidence, and resolution history. +- `Ticket`: durable work record containing intent, requirements, decisions, useful results/evidence, and immutable closure history. Its case state is only `open` or `closed`; open is not readiness, and closed is not necessarily fulfillment. +- `Classification tag`: Workspace-owned stable identity with a mutable display name. Multiple tags classify/filter Tickets; they never substitute for closure reasons, delegation or start authority. +- `Orchestrator handling`: self-reported `waiting` / `inprogress` / `done` on the current assignment row, with waiting reason, progress, next action and result. These are not case states, Worker states, MR judgments or delivery status. - `Objective`: a first-class medium-term goal with background, strategy, success criteria, and canonical Ticket context links. It neither schedules nor authorizes Ticket work. - `Task`: session-local progress tracking, not the project record. - `Assignment`: an authorized delegation of Ticket work to a Worker, independent from its Profile or Flow. @@ -25,13 +27,17 @@ The Server resolves Workspace access, active configuration, and immutable Profil `SpawnTicketWorker` starts and atomically assigns a generic Worker with an `initial_request`. Its registered `profile` is optional (omitted/null selects `builtin:ticket-worker`); `flow` and alias-keyed `workdir_attachments` are optional. No repository or Flow is required for non-code work. Choose an explicit Profile when its tools and instructions fit the task; assignment does not depend on choosing the Coder role. Backend validates claims, derives repository attachment access from declared Ticket targets, and preserves explicit ExternalGrant ceilings. Callers cannot request capabilities through attachment selection. -The guarded start records acceptance only after spawn, initial input, assignment, and attachment finalization. Do not pre-write `inprogress` or mistake a state update for a launch. On a failed or unknown outcome, reread durable operation evidence before retrying or spawning a replacement. After acceptance, reread Ticket state and current Worker responsibility. +Orchestrator Assign delegates handling and persists initial waiting with the assignment/audit/receipt; no separate ready/Queue operation is required. The stable `workspace_agent:workspace-orchestrator` principal is distinct from the concrete registered Worker. Answers during waiting/ongoing work continue the same assignment; status is explicitly self-reported, never automatically set on delivery or acceptance. An explicit reissue after done creates a new assignment row even for the same principal, preserving prior result/release/replacement history. Reopen and Worker exchange alone create neither a fresh delegation nor execution rights. See [assignment tracking](ticket-assignment-tracking.md) and [delivery/start fences](ticket-assignment-delivery.md). + +The guarded start records acceptance only after spawn, initial input, assignment, and attachment finalization. Current Orchestrator starts (including generic Worker spawn and existing-Worker assignment) must carry the observed `orchestrator_assignment_id`. Do not pre-write handling `inprogress` or mistake a case/status update for a launch. On a failed or unknown outcome, reread durable operation evidence before retrying the same operation; never silently spawn a replacement. After acceptance, reread current Worker responsibility and authoritative unfinished work, not merely `current_worker`. Unfinished dependencies are diagnostics for partial work/waiting, not a blanket start refusal, and are not implicitly started; actual authentication, case closure, resource/claim and recovery guards still apply. Results can be returned in conversation, optionally recorded in a `TicketComment`, optionally published as a Drive document/artifact when that surface is available, or written/published to an authorized repository. Neither comments nor Drive artifacts are universally required. Preserve concise evidence useful to the user without fabricating work, tests, or conclusions. `implementation_report` is optional historical/audit context, not integration readiness or completion authority. The return choice is independent of result format. Examples: -- Research with review not required: answer the question and optionally record a useful summary; no MR or approval is required for an authorized completion decision. +- Research returned to the human: record handling done with a bounded result; leave the case open when no closure was requested. No MR or universal approval is required. +- Explicit user confirmation before continuing: record waiting with the specific reason and next action. An answer continues the same assignment; receipt alone is not an inprogress self-report. +- Whole request fulfilled and closure requested: separately record handling and a reasoned case close with `completed`. Other closure reasons (not planned, duplicate, unknown, or custom) do not claim fulfillment. - Code requiring independent review before merge: use the trusted review path and guarded MR integration. A Profile, Flow transition, tool invocation, or prose approval is not the review judgment. - Return without conclusion: report the result/limitations and leave the Ticket unconcluded. A finished turn, an artifact, or an approved MR does not itself decide completion. @@ -41,9 +47,11 @@ These are instruction/tool contracts, not a claim that a mechanism proves real a `QueryTicket` provides bounded discovery/filtering; `ShowTicket` provides authoritative item content and `content_digest`, paged thread, relations, Objectives, and any current MR context. Read the relevant Ticket before implementation, routing, review, state, or conclusion decisions. Check potential duplicates before creation or material rescope. Use `QueryObjective` and `ShowObjective` for broader context without replacing the Ticket read. -Useful mutation surfaces include `TicketCreate`, `TicketComment`, relation/plan tools, and authorized `TicketWorkflowState`, `CompleteTicket`, and `TicketClose` decisions. Exposure depends on the effective Profile/Feature configuration. Decision tools use current `content_digest` as `expected_content_digest`, expected state, a reason/resolution, stable operation key, and optional supporting references. Backend checks actor authority, CAS, replay, and resource boundaries. It does not enforce a universal state sequence, MR/approval gate, or natural-language satisfaction. +Useful mutation surfaces include `TicketCreate`, `TicketComment`, classification and relation/plan tools, `TicketOrchestratorAssign` (explicit delegation or reissue), `TicketOrchestratorClear`, fenced `TicketAssignmentAnswer`, `TicketAssignmentTrackingUpdate`, and `TicketCaseUpdate`. Read current tracking first; use `TicketAssignmentsQuery` for bounded retained rows and `TicketAssignmentDiagnostics` for sanitized separate input/start evidence. `QueryTicket` supports exact current principal and self-reported handling filters. Exposure depends on the effective Profile/Feature configuration and does not grant authority. Ready, Queue, six-state transitions and `CompleteTicket`/`TicketClose` are not current Worker operations; old events remain historical evidence. -`CompleteTicket` records completion; `TicketClose` records closure. Neither integrates an MR or expands resource grants. Ticket progress/conclusion can occur without commits, MRs, or approvals when consistent with the request. Conversely, code's default publication/review practices must not be erased by confusing an independent Ticket decision with merge authority. Do not claim an unmerged result was integrated. Reread after conflicts and replay a recorded operation only with the same fingerprint. +Assign/reissue uses target principal, explicitly observed nullable `expected_assignment_id`, and stable `operation_id`; clear uses the observed assignment_id and operation_id. These role operations do not start/stop Workers. Answers use the same current assignment/event CAS and stable operation_key with bounded body, never an unfenced comment to manufacture continuation. Case judgments use current `content_digest` as `expected_content_digest`, expected open/closed state, explicitly observed nullable `closure_operation_key`, typed close/reopen/correct-closure action, explanation, stable operation key and optional references. Tracking updates use immutable `assignment_id`, observed `last_event_id` as `expected_last_event_id`, stable `operation_key` and the complete handling payload. Tag rename/delete mutations use stable tag identity and observed `expected_last_operation_key`; Ticket tag-set updates compare the observed full `expected_tag_ids` set and send the complete desired `tag_ids` set, never a display name or status-only comparison. Exact retry keeps the same key and entire input; changed input under that key is rejected. Replayed receipts are not fresh observations. After conflict, reread and reconcile, never silently overwrite or retry as a new operation against another assignment. Unknown dispatch outcomes are not success or failure. Saved Assign plus failed notification is not failed delegation and must not prompt another Assign. Do not infer missing delivery/acceptance diagnostics from Worker or handling state; report the concrete unavailable endpoint. + +`TicketCaseUpdate` records an authenticated close, reopen or correction judgment with immutable reason/actor/history. It neither integrates an MR nor expands grants nor changes handling status. Handling done and case closed do not imply one another; done does not require unassignment. Reopen alone is not reissue or old Worker revival. Generic closure can occur without commits, MRs or approvals when consistent with the request, while explicit user approval conditions must be respected. Backend checks actor authority, CAS, replay and resource boundaries, not natural-language satisfaction. Code's guarded MR integration remains separate: do not claim an unmerged result was integrated. Relations remain metadata, not a scheduling mechanism: record actual project dependencies, but do not encode capacity, Worker/worktree ownership, or execution ordering as relations. Use orchestration/session records for those concerns and inspect current Backend claim rules rather than inferring launch eligibility from a relation alone. @@ -80,7 +88,7 @@ Intake clarifies ambiguous intent, checks duplicates and relevant context, and r A useful Ticket records background/motivation, requirements, acceptance criteria, binding decisions/invariants, implementation latitude, and relevant open questions/risks. Include useful result evidence and final resolution when appropriate. Separate confirmed facts, user claims, hypotheses, and undecided questions. Do not copy Worker role boundaries, a temporary plan, or a decision to stop after authoring into the Ticket as a product prohibition. Record an approval gate only when the user asks for it; do not prematurely prescribe implementation tactics. -Risk flags prompt context checks and reviewer focus, not automatic stop gates. Return to planning only for a named missing decision/information after bounded checks; escalate beyond-scope decisions without inventing new constraints. +Risk flags prompt context checks and reviewer focus, not automatic stop gates. Record a waiting reason and next action only for a named missing decision/information after bounded checks; escalate beyond-scope decisions without inventing new constraints or moving the case to a planning state. Do not create new umbrella/progress-container Tickets. Split broad work into concrete useful outcomes, record the split in Ticket/Objective context, and use Objectives for enduring medium-term goals. Do not replace umbrellas with parent/child, sub-ticket, part-of, contains, or other hierarchy relations. A concrete planning/design/investigation Ticket is valid when requested. Existing umbrellas can be retired as superseded/decomposed without rewriting history; distinguish container retirement from completion of future work. diff --git a/docs/report/T-743-T748-callers.md b/docs/report/T-743-T748-callers.md new file mode 100644 index 000000000..510ee2fd0 --- /dev/null +++ b/docs/report/T-743-T748-callers.md @@ -0,0 +1,78 @@ +# T-743 → T-748: published domain-helper / caller handoff + +## Source identity and composition + +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-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..43e04364e --- /dev/null +++ b/docs/report/T-743-public-surface-validation.md @@ -0,0 +1,58 @@ +# 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 combined working snapshot: + +- 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 + +T-748's DB execution facade is **not** copied or integrated. Its latest notified candidate is local-only and its full Server suite has unresolved failures. When T-748 publishes a validated source, normal-merge it, place each new whole synchronous domain helper/authority query into the common bounded DB execution boundary, keep the answer transaction intact, and leave Runtime/network waiting off the DB worker. Recheck cancellation/overload and commit-before-publication at the combined boundary. The exact combined published source then needs independent trusted review. + +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/openapi/server-api.json b/openapi/server-api.json index a8b846fa9..4c00e0bf1 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -10607,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": [ { @@ -10791,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": { @@ -11050,6 +11254,47 @@ ], "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", @@ -12821,6 +13066,17 @@ "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": { @@ -12828,6 +13084,17 @@ }, "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", @@ -12848,6 +13115,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, @@ -16834,7 +17112,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b" + "x-yoi-source-digest": "sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9" }, "openapi": "3.1.0", "paths": { @@ -36842,6 +37120,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", @@ -38897,6 +39516,240 @@ } } }, + "/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", @@ -39129,6 +39982,113 @@ ] } }, + "/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", 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/tickets.md b/resources/prompts/common/tickets.md index 37b703801..99854b6ee 100644 --- a/resources/prompts/common/tickets.md +++ b/resources/prompts/common/tickets.md @@ -10,4 +10,14 @@ Choose a result and return path suited to the request: conversation, an optional 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/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 b6f634ffa..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 `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 31f88c771..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. 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. After start, reread the Ticket and verify the new 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. +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. diff --git a/resources/prompts/role/ticket-worker.md b/resources/prompts/role/ticket-worker.md index 111160a50..f90b9465b 100644 --- a/resources/prompts/role/ticket-worker.md +++ b/resources/prompts/role/ticket-worker.md @@ -1,6 +1,6 @@ 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 `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. 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 5a25ed37b..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/api/ticket-case.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 d40e0565a..e01e53961 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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 307f65277..a8d0103f6 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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 516e54f07..fa8a2d416 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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 7c9829198..1e14cc770 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 @@ -87,6 +87,16 @@ export type SetTicketRoleAssignmentRequest = { expected_assignment_id?: string | 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"; }; @@ -97,6 +107,8 @@ 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 TicketAssignmentTracking = { next_action?: string | null; progress?: string | null; result?: string | null; status: TicketAssignmentTrackingStatus; waiting_reason?: string | null; }; @@ -111,6 +123,8 @@ 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; }; @@ -155,7 +169,7 @@ export type TicketMergeRequestSummary = { current_subject_ref?: string | null; i 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 = { attention?: Array<TicketAttention>; cursor?: string | null; event_kinds?: Array<string>; evidence?: Array<string>; 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 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; }; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index 19d7b3edb..6ce0504f5 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:ba017607e313ba3eb500e4eb78f94c2bd95a08acde96688b8d2a0014abd94ea3) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 3868137c5..27665034f 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:8c8254cbe4ca8675f3dfffdf6e0eeecfda1c9213b285f571704648774efa497b) +// Input contract: 0.1.0 (sha256:6ab084f6f5a2be53d373d869aa0202da571a6ff6a16852683958a23305bce7a9) // 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 diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index 6b2135702..ade684b98 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -41,6 +41,8 @@ import type { TicketListItemSummary, TicketListResponse, TicketMergeRequestSummary, + TicketQueryItem, + TicketQueryResponse, TicketQueueOutcome, TicketRef, TicketReference, @@ -335,7 +337,10 @@ function caseProjection(item: Record<string, unknown>, label: string) { }; } -function parseWorkspaceTag(value: unknown, label: string): WorkspaceTag { +export function parseWorkspaceTag( + value: unknown, + label = "Workspace tag", +): WorkspaceTag { const item = object(value, label); exact(item, [ "workspace_id", @@ -652,7 +657,7 @@ function parseTicketRoleAssignmentRecord( }; } -function parseAssignmentTracking( +export function parseAssignmentTracking( value: unknown, label: string, ): TicketAssignmentTracking { @@ -722,7 +727,7 @@ function parseAssignmentRelease( }; } -function parseAssignmentTrackingProjection( +export function parseAssignmentTrackingProjection( value: unknown, label: string, ): TicketAssignmentTrackingProjection { @@ -1750,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-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 df1dc3d0f..e234d9a51 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,16 +392,13 @@ 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: "Open"') && @@ -428,14 +425,16 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as assert( ticketDetailLoad.includes("/repositories") && ticketDetailPage.includes("ticketCaseRequest(ticket, action") && - ticketDetailPage.includes("async function queueTicket") && + 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("await updateTicketCase(") && + 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 501890012..1818609cb 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -49,6 +49,7 @@ export function fixtureDetail(key = "T-101") { artifacts: [], artifact_count: 0, assignments: [], + assignment_tracking: [], assignment_diagnostics: [], events: [], event_count: 0, 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 da95d49f6..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"; @@ -112,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 1a2a388d4..5e743bd62 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -1,7 +1,13 @@ 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 = ["open", "closed"] as const; @@ -16,10 +22,14 @@ 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" ->; +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; @@ -81,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]/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} +
+

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} +
+
+ + 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 383828e4a..e8c961a31 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -11,27 +11,28 @@ import { TICKET_BROWSER_API_LOAD_POLICY, TICKET_BROWSER_API_MAX_RESPONSE_BYTES, - parseTicketListResponse, + parseTicketQueryResponse, parseTicketRecordRef, } from "#lib/workspace/api/ticket-browser.ts"; import type { NewTicket, QueryPage, - TicketListItemSummary as TicketSummary, + TicketQueryItem as TicketSummary, + TicketState, TicketTarget, TicketTargetAccess, } from "#lib/generated/ticket-api.ts"; import { ticketHref } from "#lib/workspace/resource-links.ts"; import { - ticketLanes, + ticketQueryRequest, currentOrchestratorAssignment, assignmentPrincipalLabel, handlingLabel, handlingSummary, closureReasonLabel, type WorkspaceOrchestratorStatus, } from "#lib/workspace/tickets/ticket-panel.ts"; import type { PageData } from "./$types"; type LaneState = { - states: string[]; + states: TicketState[]; tickets: TicketSummary[]; - page: QueryPage; + page: QueryPage | null; loading: boolean; error: string | null; }; @@ -43,21 +44,19 @@ }; let { data }: { data: PageData } = $props(); - // svelte-ignore state_referenced_locally - let laneState = $state>( - Object.fromEntries( - Object.entries(data.ticketLanes).map(([laneId, lane]) => [ - laneId, - { - states: [...lane.states], - tickets: lane.response.items, - page: lane.response.page, - loading: false, - error: null, - }, - ]), - ), - ); + function initialLanes(): Record { + return Object.fromEntries(Object.entries(data.ticketLanes).flatMap(([laneId, result]) => result ? [[laneId, { + states: [laneId as TicketState], tickets: result.data?.items ?? [], page: result.data?.page ?? null, + loading: false, error: result.error, + }]] : [])); + } + let laneState = $state>(untrack(initialLanes)); + let principal = $state(untrack(() => data.filters.principal)); + let closureChoice = $state(untrack(() => data.filters.closureReason)); + let filtersBusy = $state(false); + $effect(() => { const currentData = data; untrack(() => { + laneState = initialLanes(); principal = currentData.filters.principal; closureChoice = currentData.filters.closureReason; orchestrator = currentData.orchestrator; + }); }); let orchestrator = $state>( untrack(() => data.orchestrator), ); @@ -81,10 +80,16 @@ } return true; }); - const tickets = $derived( - Object.values(laneState).flatMap((lane) => lane.tickets), - ); - const lanes = $derived(ticketLanes(tickets)); + const lanes = $derived(Object.entries(laneState).map(([id, lane]) => ({ id, label: id === "open" ? "Open" : "Closed", tickets: lane.tickets }))); + + async function applyFilters(event: SubmitEvent) { + event.preventDefault(); + const form = event.currentTarget as HTMLFormElement; + const search = new URLSearchParams(); + for (const [key, value] of new FormData(form)) if (typeof value === "string" && value) search.append(key, value); + filtersBusy = true; + try { await goto(`?${search}`); } finally { filtersBusy = false; } + } function mergeTickets( current: TicketSummary[], @@ -97,22 +102,17 @@ async function loadMore(laneId: string): Promise { const lane = laneState[laneId]; - if (!lane || lane.loading || !lane.page.has_more || !lane.page.next_cursor) { + if (!lane || lane.loading || (lane.page && !lane.page.has_more)) { return; } lane.loading = true; lane.error = null; try { - const search = new URLSearchParams({ - limit: "30", - states: lane.states.join(","), - cursor: lane.page.next_cursor, - }); const result = await loadJson( fetch, - `/api/w/${encodeURIComponent(data.workspaceId)}/tickets?${search}`, - undefined, - parseTicketListResponse, + workspaceApiPath(data.workspaceId, "/tickets/query"), + { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify(ticketQueryRequest(data.filters, lane.states, lane.page?.next_cursor ?? null)) }, + parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY, ); if (!result.data) { @@ -127,13 +127,6 @@ } } - function handleLaneScroll(event: Event, laneId: string): void { - const container = event.currentTarget as HTMLElement; - const remaining = - container.scrollHeight - container.scrollTop - container.clientHeight; - if (remaining <= 96) void loadMore(laneId); - } - function addCreateTarget(): void { createTargets.push({ repository_key: "", @@ -227,9 +220,9 @@
Orchestrator - {orchestrator.data?.online ? "Online" : "Offline"} + {orchestrator.data ? orchestrator.data.online ? "Online" : "Offline" : "Unavailable"}
- {#if !orchestrator.data?.online} + {#if orchestrator.data && !orchestrator.data.online} + Orchestrator handling + + +
{#each lanes as lane (lane.id)} {@const pagination = laneState[lane.id]}
@@ -310,14 +316,14 @@

{lane.label}

- {lane.tickets.length} + {lane.tickets.length} loaded - {:else if pagination && !pagination.page.has_more && lane.tickets.length > 0} + {:else if pagination?.page?.has_more} + + {:else if pagination?.page && !pagination.page.has_more && lane.tickets.length > 0}

End of lane

{/if} @@ -347,3 +362,22 @@ {/each}
+ + diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts index 9721b4e1d..0078b50b1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts @@ -2,56 +2,52 @@ import { parseBrowserWorkspaceOrchestratorResponse } from "#lib/workspace/api/wo import { loadWorkspaceRepositoryList } from "#lib/workspace/api/repositories.ts"; import { loadJson, workspaceApiPath } from "#lib/workspace/api/http.ts"; import { - parseTicketListResponse, + parseTicketQueryResponse, TICKET_BROWSER_API_LOAD_POLICY, } from "#lib/workspace/api/ticket-browser.ts"; -import type { TicketListResponse } from "#lib/generated/ticket-api.ts"; -import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts"; +import { parseTags } from "#lib/workspace/api/ticket-operations.ts"; +import { + readTicketFilters, + ticketQueryRequest, +} from "#lib/workspace/tickets/ticket-panel.ts"; +import type { TicketState } from "#lib/generated/ticket-api.ts"; import type { PageLoad } from "./$types"; -const LANE_STATES = { open: ["open"], closed: ["closed"] } as const; - -export type TicketLaneId = keyof typeof LANE_STATES; - -export type TicketLanePage = { - states: readonly string[]; - response: TicketListResponse; -}; - -async function loadLane( - fetchFn: typeof fetch, - workspaceId: string, - laneId: TicketLaneId, -): Promise { - const states = LANE_STATES[laneId]; - const search = new URLSearchParams({ - limit: "30", - states: states.join(","), - }); - const result = await loadJson( - fetchFn, - `/api/w/${encodeURIComponent(workspaceId)}/tickets?${search}`, - undefined, - parseTicketListResponse, - TICKET_BROWSER_API_LOAD_POLICY, - ); - if (!result.data) { - throw new Error(result.error ?? `failed to load ${laneId} Ticket lane`); - } - return { states: [...states], response: result.data }; -} - -export const load: PageLoad = async ({ fetch, params }) => { +export const load: PageLoad = async ({ fetch, params, url }) => { const workspaceId = params.workspaceId; - const [ - open, - closed, - orchestrator, - repositories, - ] = await Promise.all([ - loadLane(fetch, workspaceId, "open"), - loadLane(fetch, workspaceId, "closed"), - loadJson( + const filters = readTicketFilters(url.searchParams); + const loadLane = async (state: TicketState) => { + if (filters.caseState !== "all" && filters.caseState !== state) return null; + try { + return await loadJson( + fetch, + workspaceApiPath(workspaceId, "/tickets/query"), + { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(ticketQueryRequest(filters, [state])), + }, + parseTicketQueryResponse, + TICKET_BROWSER_API_LOAD_POLICY, + ); + } catch (error) { + return { + data: null, + error: error instanceof Error ? error.message : String(error), + }; + } + }; + const [open, closed, tags, orchestrator, repositories] = await Promise.all([ + loadLane("open"), + loadLane("closed"), + loadJson( + fetch, + workspaceApiPath(workspaceId, "/ticket-tags"), + undefined, + parseTags, + TICKET_BROWSER_API_LOAD_POLICY, + ), + loadJson( fetch, workspaceApiPath(workspaceId, "/orchestrator"), undefined, @@ -59,13 +55,11 @@ export const load: PageLoad = async ({ fetch, params }) => { ), loadWorkspaceRepositoryList(fetch, workspaceId), ]); - return { workspaceId, - ticketLanes: { - open, - closed, - }, + filters, + ticketLanes: { open, closed }, + tags, orchestrator, repositories, }; diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index b2536cc0c..cd2b07a74 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -1,5 +1,9 @@