diff --git a/crates/agen/src/engine.rs b/crates/agen/src/engine.rs index d35c1778b..8638332d4 100644 --- a/crates/agen/src/engine.rs +++ b/crates/agen/src/engine.rs @@ -937,7 +937,8 @@ impl Engine { /// /// The callback is invoked with a short human-readable message /// whenever the Engine encounters a condition that should be - /// surfaced to a human (e.g. tool output byte-cap truncation). + /// surfaced to a human (e.g. a provider-rejected image). Tool output + /// byte-cap truncation is reported in the tool result, not this channel. /// This channel is separate from `tracing::warn!`, which remains /// in place for developer logs. pub fn on_warning(&mut self, callback: impl Fn(&str) + Send + Sync + 'static) { @@ -2379,13 +2380,9 @@ impl Engine { limit_bytes = limit, "Tool output exceeded byte limit and was truncated" ); - self.emit_warning(&format!( - "tool `{}` output truncated from {} to {} bytes (limit {})", - tool_call.name, - before, - content.len(), - limit - )); + // Truncation is a tool-result constraint, not an actionable + // engine warning. The content marker tells the model to refine + // its query; keep diagnostics in tracing, not user alerts. } } diff --git a/crates/agen/tests/callback_test.rs b/crates/agen/tests/callback_test.rs index 1c3535f8e..162cbfcec 100644 --- a/crates/agen/tests/callback_test.rs +++ b/crates/agen/tests/callback_test.rs @@ -16,6 +16,182 @@ use agen::tool::{Tool, ToolDefinition, ToolError, ToolMeta, ToolOutput}; use async_trait::async_trait; use common::MockLlmClient; +/// A per-run tracing sink: no global subscriber or output-body logging. +#[derive(Clone)] +struct DiagnosticLog(Arc>>); + +impl std::io::Write for DiagnosticLog { + fn write(&mut self, bytes: &[u8]) -> std::io::Result { + self.0.lock().unwrap().extend_from_slice(bytes); + Ok(bytes.len()) + } + + fn flush(&mut self) -> std::io::Result<()> { + Ok(()) + } +} + +#[tokio::test] +async fn output_limits_keep_partial_results_without_warning_callbacks() { + use agen::tool::{Attachment, ImageAttachment, ToolOutputLimits, ToolResult}; + use agen::{History, Item, ToolResultDisposition}; + use tracing::instrument::WithSubscriber; + + let limit = 256; + let cases = [ + ("below", "small result".to_string(), limit), + ("exact", "x".repeat(limit), limit), + ("Grep", "matched line\n".repeat(200), limit), + ("Read", "あいうえお".repeat(200), 120), + ]; + let mut events = Vec::new(); + for (index, name) in cases.iter().map(|c| c.0).chain(["erroring"]).enumerate() { + events.extend([ + Event::tool_use_start(index, name, name), + Event::tool_input_delta(index, "{}"), + Event::tool_use_stop(index), + ]); + } + events.push(Event::Status(ClientStatusEvent { + status: ResponseStatus::Completed, + })); + let client = MockLlmClient::with_responses(vec![ + events, + vec![Event::Status(ClientStatusEvent { + status: ResponseStatus::Completed, + })], + ]); + let probe = client.clone(); + let mut engine = Engine::new(client); + engine.set_tool_call_dispatch_mode(agen::ToolCallDispatchMode::AfterResponse); + engine.set_tool_output_limits(Some(ToolOutputLimits { + default_max_bytes: limit, + per_tool: [("Read".to_string(), 120)].into(), + })); + let attachments = vec![Attachment::Image(ImageAttachment::new( + "image/png", + b"\x89PNG\r\n\x1a\n\0\0\0\rIHDR\0\0\0\x01\0\0\0\x01".to_vec(), + ))]; + for (name, content, _) in &cases { + engine.register_tool(fixed_tool( + name, + ToolOutput { + summary: format!("result from {name}"), + content: Some(content.clone()), + attachments: attachments.clone(), + }, + )); + } + engine.register_tool(erroring_tool("erroring", "execution failed")); + + let warnings = Arc::new(Mutex::new(Vec::new())); + let sink = warnings.clone(); + engine.on_warning(move |warning| sink.lock().unwrap().push(warning.to_owned())); + let published = Arc::new(Mutex::new(Vec::::new())); + let sink = published.clone(); + engine.on_tool_result(move |result| sink.lock().unwrap().push(result.clone())); + let committed = Arc::new(Mutex::new(Vec::new())); + let sink = committed.clone(); + engine.on_history_append(move |item| { + if matches!(item, Item::ToolResult { .. }) { + sink.lock().unwrap().push(item.clone()); + } + Ok(()) + }); + + let log = DiagnosticLog(Arc::new(Mutex::new(Vec::new()))); + let writer = log.clone(); + let subscriber = tracing_subscriber::fmt() + .without_time() + .with_ansi(false) + .with_max_level(tracing::Level::WARN) + .with_writer(move || writer.clone()) + .finish(); + let mut history = History::new(); + let run = engine + .run(&mut history, "run tools") + .with_subscriber(subscriber) + .await; + assert!(matches!(run.result, agen::EngineRunExit::Finished)); + assert!(warnings.lock().unwrap().is_empty()); + + let requests = probe.requests(); + assert_eq!(requests.len(), 2); + let saved: Vec<_> = history + .iter() + .filter_map(|entry| matches!(entry.item, Item::ToolResult { .. }).then_some(&entry.item)) + .collect(); + assert_eq!(saved.len(), cases.len() + 1); + let committed = committed.lock().unwrap(); + let published = published.lock().unwrap(); + let logs = String::from_utf8(log.0.lock().unwrap().clone()).unwrap(); + for item in saved { + let Item::ToolResult { + call_id, + summary, + content, + disposition, + is_error, + attachments: saved_attachments, + .. + } = item + else { + unreachable!() + }; + assert!(committed.iter().any(|candidate| candidate == item)); + assert!(requests[1].items.iter().any(|candidate| candidate == item)); + let result = published + .iter() + .find(|result| result.tool_use_id == *call_id) + .unwrap(); + assert_eq!(&result.summary, summary); + assert_eq!(&result.content, content); + assert_eq!(&result.disposition, disposition); + assert_eq!(&result.is_error, is_error); + assert_eq!(&result.attachments, saved_attachments); + if call_id == "erroring" { + assert_eq!(*disposition, ToolResultDisposition::Error); + assert!(*is_error); + assert!(content.is_none()); + continue; + } + assert_eq!(*disposition, ToolResultDisposition::Success); + assert!(!*is_error); + assert_eq!(*summary, format!("result from {call_id}")); + assert_eq!(saved_attachments, &attachments); + let (_, original, cap) = cases.iter().find(|c| c.0 == call_id).unwrap(); + let content = content.as_ref().unwrap(); + assert!(content.len() <= *cap); + if original.len() <= *cap { + assert_eq!(content, original); + } else { + let (body, marker) = content.split_once("\n\n[truncated: ").unwrap(); + assert!(original.starts_with(body)); + assert_eq!( + marker, + format!( + "{} bytes dropped, refine your query]", + original.len() - body.len() + ) + ); + let diagnostic = logs + .lines() + .find(|line| line.contains(&format!("tool={call_id}"))) + .unwrap(); + assert!(diagnostic.contains(&format!("before_bytes={}", original.len()))); + assert!(diagnostic.contains(&format!("after_bytes={}", content.len()))); + assert!(diagnostic.contains(&format!("limit_bytes={cap}"))); + assert!(!diagnostic.contains(body)); + } + } + assert_eq!( + logs.lines() + .filter(|line| line.contains("Tool output exceeded byte limit")) + .count(), + 2 + ); +} + #[derive(Clone)] struct FailOnceClient { calls: Arc, diff --git a/crates/client/src/backend_workspace.rs b/crates/client/src/backend_workspace.rs index 639e7cc75..55f882111 100644 --- a/crates/client/src/backend_workspace.rs +++ b/crates/client/src/backend_workspace.rs @@ -434,11 +434,11 @@ mod tests { let request = CreateBackendWorkspaceRequest { operation_key: "workspace-create-1".to_string(), display_name: "Alpha".to_string(), - repository: CreateBackendWorkspaceRepository { + repository: Some(CreateBackendWorkspaceRepository { repository_key: "main".to_string(), uri: "/srv/repos/alpha".to_string(), default_ref: Some("develop".to_string()), - }, + }), }; let retry = request.clone(); diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index c65d1b4a2..773d3e505 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -155,14 +155,40 @@ 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_item_revision: ticket::ticket_item_revision(¤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, ) -> Result<(), BackendWorkspaceClientError> { + let change = self.decision_from_state_change(id, change)?; let workspace_id = self.workspace_id.clone(); let id = ticket_reference(id); - let change = change.clone(); self.generated(move |client| async move { client .ticket_workflow_state_set( @@ -179,15 +205,23 @@ impl BackendWorkspaceProductClient { 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_item_revision: ticket::ticket_item_revision(¤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); - let resolution = resolution.clone(); self.generated(move |client| async move { client .ticket_close_record( workspace_id, id, - server_api::TicketCloseRecordRequest(resolution), + server_api::TicketCloseRecordRequest(request), ) .await }) @@ -633,6 +667,9 @@ impl TicketBackend for BackendWorkspaceProductClient { 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 { @@ -672,6 +709,14 @@ impl TicketBackend for BackendWorkspaceProductClient { 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(); @@ -697,6 +742,26 @@ impl TicketBackend for BackendWorkspaceProductClient { .map_err(ticket_client_error) } + fn update_state( + &self, + ticket: &str, + request: ticket::TicketStateUpdate, + ) -> ticket::Result { + let workspace_id = self.workspace_id.clone(); + let id = ticket.to_owned(); + self.generated(move |client| async move { + client + .ticket_state_update( + workspace_id, + id, + server_api::TicketStateUpdateRequest(request), + ) + .await + .map(|response| response.0) + }) + .map_err(ticket_client_error) + } + fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result { let workspace_id = self.workspace_id.clone(); let id = ticket_reference(&id); @@ -903,8 +968,12 @@ mod tests { } fn response_sequence_server( - responses: Vec<(&'static str, &'static str)>, + responses: Vec<(&str, &str)>, ) -> (String, mpsc::Receiver, thread::JoinHandle<()>) { + let responses = responses + .into_iter() + .map(|(status, body)| (status.to_owned(), body.to_owned())) + .collect::>(); let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let (sender, receiver) = mpsc::channel(); @@ -927,6 +996,117 @@ mod tests { (format!("http://{address}"), receiver, handle) } + #[test] + fn legacy_state_adapter_uses_snapshot_revision_without_overriding_stale_expected_state() { + let temp = tempfile::tempdir().unwrap(); + let backend = + ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") + .unwrap(); + let reference = backend.create(NewTicket::new("Adapter snapshot")).unwrap(); + let snapshot = backend.show(reference.id.clone().into()).unwrap(); + let body = serde_json::to_string(&snapshot).unwrap(); + let (base_url, requests, handle) = + response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]); + let client = + BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token") + .unwrap(); + client + .set_ticket_workflow_state( + &reference.id.clone().into(), + &TicketStateChange::new("ready", "done", "Judgment", "Results in the thread"), + ) + .unwrap(); + assert!(requests.recv().unwrap().starts_with("GET ")); + let request = requests.recv().unwrap(); + let body: serde_json::Value = + serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!( + body["expected_item_revision"], + ticket::ticket_item_revision(&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_item_revision"], + ticket::ticket_item_revision(&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 revision","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_item_revision: "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_item_revision"], "ticket:1"); + assert_eq!(body["expected_state"], "planning"); + assert_eq!(body["reason"], "Research findings recorded"); + assert!(body.get("merge_request_ids").is_none()); + handle.join().unwrap(); + } + #[test] fn memory_document_uses_shared_workspace_scoped_response() { let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##; diff --git a/crates/client/tests/backend_workspace.rs b/crates/client/tests/backend_workspace.rs index accd61fc2..8920bdcc8 100644 --- a/crates/client/tests/backend_workspace.rs +++ b/crates/client/tests/backend_workspace.rs @@ -8,11 +8,11 @@ fn workspace_creation_request_preserves_operation_key_for_retry() { let request = CreateBackendWorkspaceRequest { operation_key: "workspace-create-1".to_string(), display_name: "Alpha".to_string(), - repository: CreateBackendWorkspaceRepository { + repository: Some(CreateBackendWorkspaceRepository { repository_key: "main".to_string(), uri: "/srv/repos/alpha".to_string(), default_ref: Some("develop".to_string()), - }, + }), }; assert_eq!(request.clone(), request); diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 78a40bbd5..c0114b142 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -236,10 +236,244 @@ pub struct MergeRequest { #[serde(default)] pub thread: Vec, } +/// Bounded failures from pure evaluation of persisted review and merge evidence. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum MergeRequestEvidenceError { + NotMerged, + MergeResultMissing, + ApprovalMissing, + ApprovalRevoked, + ApprovalNotApproved, + ApprovalSourceMismatch, + ApprovalTimelineMismatch, + ReviewRequestMissing, + ReviewRequestMismatch, + ApprovalNotEffective, + ItemRevisionMismatch, + SourceSnapshotMismatch, + RequirementApprovalMissing, +} + +impl MergeRequestEvidenceError { + /// Stable machine-readable diagnostic code, separate from the human message. + pub fn code(self) -> &'static str { + match self { + Self::NotMerged => "not_merged", + Self::MergeResultMissing => "merge_result_missing", + Self::ApprovalMissing => "approval_missing", + Self::ApprovalRevoked => "approval_revoked", + Self::ApprovalNotApproved => "approval_not_approved", + Self::ApprovalSourceMismatch => "approval_source_mismatch", + Self::ApprovalTimelineMismatch => "approval_timeline_mismatch", + Self::ReviewRequestMissing => "review_request_missing", + Self::ReviewRequestMismatch => "review_request_mismatch", + Self::ApprovalNotEffective => "approval_not_effective", + Self::ItemRevisionMismatch => "item_revision_mismatch", + Self::SourceSnapshotMismatch => "source_snapshot_mismatch", + Self::RequirementApprovalMissing => "requirement_approval_missing", + } + } + + pub fn as_str(self) -> &'static str { + match self { + Self::NotMerged => "Merge Request has no merged result", + Self::MergeResultMissing => "merged Merge Request has no MergeResult", + Self::ApprovalMissing => "integration approval is missing", + Self::ApprovalRevoked => "Reviewer approval is revoked", + Self::ApprovalNotApproved => "Reviewer decision is not an approval", + Self::ApprovalSourceMismatch => "Reviewer approval does not match the exact source ref", + Self::ApprovalTimelineMismatch => { + "integration approval does not predate the merged result" + } + Self::ReviewRequestMissing => "linked ReviewRequested evidence is missing", + Self::ReviewRequestMismatch => { + "Reviewer approval does not match linked ReviewRequested evidence" + } + Self::ApprovalNotEffective => "Reviewer approval is not the effective review", + Self::ItemRevisionMismatch => { + "Reviewer approval does not attest the current Ticket revision" + } + Self::SourceSnapshotMismatch => { + "Reviewer approval does not attest the exact linked source set" + } + Self::RequirementApprovalMissing => { + "no effective Reviewer approval attests the current Ticket revision and exact linked source set" + } + } + } +} + +impl std::fmt::Display for MergeRequestEvidenceError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + formatter.write_str(self.as_str()) + } +} + +impl std::error::Error for MergeRequestEvidenceError {} + impl MergeRequest { pub fn effective_review(&self, subject: &str) -> Option<&ReviewEvent> { self.thread.iter().rev().find_map(|e|match e{MergeRequestThreadEvent::Review(v)if v.subject_ref==subject&&!self.thread.iter().any(|x|matches!(x,MergeRequestThreadEvent::ReviewRevoked(r)if r.review_event_id==v.event_id))=>Some(v),_=>None}) } + + /// Returns the recorded merged result, without resolving selectors or mutating state. + pub fn merged_result(&self) -> Result<&MergeEvent, MergeRequestEvidenceError> { + if self.state != MergeRequestState::Merged { + return Err(MergeRequestEvidenceError::NotMerged); + } + self.thread + .iter() + .rev() + .find_map(|event| match event { + MergeRequestThreadEvent::Merge(event) => Some(event), + _ => None, + }) + .ok_or(MergeRequestEvidenceError::MergeResultMissing) + } + + /// Validates the specific approval recorded by the merged result. A later + /// requirement review does not replace this integration evidence. + pub fn integration_approval(&self) -> Result<&ReviewEvent, MergeRequestEvidenceError> { + let merge = self.merged_result()?; + let review = self + .thread + .iter() + .find_map(|event| match event { + MergeRequestThreadEvent::Review(review) + if review.event_id == merge.approval_event_id => + { + Some(review) + } + _ => None, + }) + .ok_or(MergeRequestEvidenceError::ApprovalMissing)?; + if merge.approved_source_ref.trim().is_empty() + || review.subject_ref != merge.approved_source_ref + { + return Err(MergeRequestEvidenceError::ApprovalSourceMismatch); + } + if review.sequence >= merge.sequence { + return Err(MergeRequestEvidenceError::ApprovalTimelineMismatch); + } + self.approval_evidence(review)?; + Ok(review) + } + + fn approval_evidence(&self, review: &ReviewEvent) -> Result<(), MergeRequestEvidenceError> { + if self.thread.iter().any(|event| { + matches!(event, MergeRequestThreadEvent::ReviewRevoked(revoked) + if revoked.review_event_id == review.event_id) + }) { + return Err(MergeRequestEvidenceError::ApprovalRevoked); + } + if review.decision != ReviewDecision::Approve { + return Err(MergeRequestEvidenceError::ApprovalNotApproved); + } + let requested = self + .thread + .iter() + .find_map(|event| match event { + MergeRequestThreadEvent::ReviewRequested(requested) + if requested.event_id == review.request_event_id => + { + Some(requested) + } + _ => None, + }) + .ok_or(MergeRequestEvidenceError::ReviewRequestMissing)?; + if requested.subject_ref != review.subject_ref + || requested.ticket_item_revision != review.ticket_item_revision + || requested.ticket_merge_request_subjects != review.ticket_merge_request_subjects + || requested.reviewer != review.reviewer + || requested.sequence >= review.sequence + { + return Err(MergeRequestEvidenceError::ReviewRequestMismatch); + } + Ok(()) + } +} + +/// Finds an effective approval of the authoritative current Ticket revision and +/// exact linked source snapshot. Callers supply all linked requests and resolved +/// source refs (recorded merged refs for completion); this function does not read +/// authority, resolve selectors, require merged state, or mutate lifecycle state. +/// An explicit event id validates that approval only; `None` discovers any valid +/// attestation. Snapshot ordering is immaterial, but duplicate ids are rejected. +pub fn requirement_approval<'a>( + requests: &'a [MergeRequest], + item_revision: &str, + subjects: &[MergeRequestReviewSubject], + event_id: Option<&str>, +) -> Result<&'a ReviewEvent, MergeRequestEvidenceError> { + if requests.len() != subjects.len() + || !same_source_snapshot(subjects, subjects) + || requests.iter().any(|request| { + requests + .iter() + .filter(|other| other.merge_request_id == request.merge_request_id) + .count() + != 1 + || !subjects + .iter() + .any(|subject| subject.merge_request_id == request.merge_request_id) + }) + { + return Err(MergeRequestEvidenceError::SourceSnapshotMismatch); + } + for request in requests { + for event in request.thread.iter().rev() { + let MergeRequestThreadEvent::Review(review) = event else { + continue; + }; + if event_id.is_some_and(|id| id != review.event_id) { + continue; + } + let evidence = (|| { + request.approval_evidence(review)?; + if !request + .effective_review(&review.subject_ref) + .is_some_and(|effective| effective.event_id == review.event_id) + { + return Err(MergeRequestEvidenceError::ApprovalNotEffective); + } + if review.ticket_item_revision != item_revision || item_revision.trim().is_empty() { + return Err(MergeRequestEvidenceError::ItemRevisionMismatch); + } + if !same_source_snapshot(&review.ticket_merge_request_subjects, subjects) { + return Err(MergeRequestEvidenceError::SourceSnapshotMismatch); + } + if !subjects.iter().any(|subject| { + subject.merge_request_id == request.merge_request_id + && subject.subject_ref == review.subject_ref + }) { + return Err(MergeRequestEvidenceError::ApprovalSourceMismatch); + } + Ok(review) + })(); + if evidence.is_ok() || event_id.is_some() { + return evidence; + } + } + } + Err(MergeRequestEvidenceError::RequirementApprovalMissing) +} + +fn same_source_snapshot( + left: &[MergeRequestReviewSubject], + right: &[MergeRequestReviewSubject], +) -> bool { + !left.is_empty() + && left.len() == right.len() + && left.iter().all(|subject| { + !subject.merge_request_id.trim().is_empty() + && !subject.subject_ref.trim().is_empty() + && left + .iter() + .filter(|other| other.merge_request_id == subject.merge_request_id) + .count() + == 1 + && right.iter().filter(|other| *other == subject).count() == 1 + }) } #[derive(Clone, Debug, Default)] @@ -364,16 +598,8 @@ pub struct CompleteMergeRequest { pub auth: MergeRequestAuth, pub now: DateTime, } -#[derive(Debug, Clone)] -pub struct CompleteTicket { - pub ticket_id: String, - pub operation_id: String, - pub item_revision: String, - pub merge_request_ids: Vec, - pub requirement_approval_event_id: String, - pub auth: MergeRequestAuth, - pub now: DateTime, -} +/// Historical MR-owned Ticket completion payload, retained for reading existing events. +/// Canonical Ticket completion is owned by the Ticket domain. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketCompletionEvent { pub operation_id: String, @@ -979,7 +1205,7 @@ impl MergeRequestStore { } let assignment_is_current: bool = transaction.query_row( "SELECT EXISTS( - SELECT 1 FROM ticket_current_worker_assignments + SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 )", params![i.auth.workspace_id, i.ticket_id, i.auth.assignment_id], @@ -1098,227 +1324,6 @@ impl MergeRequestStore { Ok(event) } - pub fn complete_ticket( - &self, - mut input: CompleteTicket, - ) -> Result { - nonempty("operation_id", &input.operation_id)?; - nonempty("item_revision", &input.item_revision)?; - nonempty( - "requirement_approval_event_id", - &input.requirement_approval_event_id, - )?; - if input.merge_request_ids.is_empty() { - return Err(MergeRequestError::NotReady( - "Ticket completion requires at least one linked Merge Request result".into(), - )); - } - input.merge_request_ids.sort(); - if input - .merge_request_ids - .windows(2) - .any(|ids| ids[0] == ids[1]) - { - return Err(MergeRequestError::Validation( - "merge_request_ids must not contain duplicates".into(), - )); - } - let mut connection = self.lock()?; - let transaction = connection.transaction()?; - let existing: Option = transaction - .query_row( - "SELECT payload.value - FROM typed_ticket_event_attributes operation - JOIN typed_ticket_event_attributes payload - ON payload.workspace_id=operation.workspace_id - AND payload.ticket_id=operation.ticket_id - AND payload.event_index=operation.event_index - AND payload.key='ticket_completion' - WHERE operation.workspace_id=?1 AND operation.ticket_id=?2 - AND operation.key='operation_id' AND operation.value=?3", - params![input.auth.workspace_id, input.ticket_id, input.operation_id], - |row| row.get(0), - ) - .optional()?; - if let Some(payload) = existing { - let event: TicketCompletionEvent = json(&payload)?; - if event.item_revision == input.item_revision - && event.merge_request_ids == input.merge_request_ids - && event.requirement_approval_event_id == input.requirement_approval_event_id - && event.completed_by == input.auth.actor() - { - return Ok(event); - } - return Err(MergeRequestError::Conflict( - "Ticket completion operation fingerprint mismatch".into(), - )); - } - let current_revision = - current_ticket_revision(&transaction, &input.auth.workspace_id, &input.ticket_id)? - .ok_or(MergeRequestError::NotFound)?; - if current_revision != input.item_revision { - return Err(MergeRequestError::Conflict(format!( - "Ticket item revision changed from `{}` to `{current_revision}`; repeat requirement review before completion", - input.item_revision - ))); - } - let state: String = transaction.query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", - params![input.auth.workspace_id, input.ticket_id], - |row| row.get(0), - )?; - if state != "inprogress" { - return Err(MergeRequestError::Conflict( - "Ticket must be inprogress".into(), - )); - } - let assignment_is_current: bool = transaction.query_row( - "SELECT EXISTS( - SELECT 1 FROM ticket_current_worker_assignments - WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 - )", - params![ - input.auth.workspace_id, - input.ticket_id, - input.auth.assignment_id - ], - |row| row.get(0), - )?; - if !assignment_is_current { - return Err(MergeRequestError::Unauthorized( - "Ticket completion assignment changed before commit".into(), - )); - } - let actual_ids = - linked_merge_request_ids(&transaction, &input.auth.workspace_id, &input.ticket_id)?; - if actual_ids != input.merge_request_ids { - return Err(MergeRequestError::Conflict( - "linked Merge Request set changed; refresh Ticket completion evidence".into(), - )); - } - let mut requests = Vec::with_capacity(actual_ids.len()); - let mut merged_subjects = Vec::with_capacity(actual_ids.len()); - for merge_request_id in &actual_ids { - let request = load_mr(&transaction, &input.auth.workspace_id, merge_request_id)? - .ok_or(MergeRequestError::NotFound)?; - if request.state != MergeRequestState::Merged { - return Err(MergeRequestError::NotReady(format!( - "Merge Request `{merge_request_id}` has no merged result" - ))); - } - let merge = request - .thread - .iter() - .rev() - .find_map(|event| match event { - MergeRequestThreadEvent::Merge(event) => Some(event), - _ => None, - }) - .ok_or_else(|| { - MergeRequestError::Corrupt(format!( - "merged Merge Request `{merge_request_id}` has no MergeResult" - )) - })?; - let review = request - .thread - .iter() - .find_map(|event| match event { - MergeRequestThreadEvent::Review(review) - if review.event_id == merge.approval_event_id - && review.subject_ref == merge.approved_source_ref => - { - Some(review) - } - _ => None, - }) - .filter(|review| { - !request.thread.iter().any(|event| { - matches!( - event, - MergeRequestThreadEvent::ReviewRevoked(revoked) - if revoked.review_event_id == review.event_id - ) - }) - }) - .ok_or_else(|| { - MergeRequestError::NotReady(format!( - "Merge Request `{merge_request_id}` integration approval is missing or revoked" - )) - })?; - if review.decision != ReviewDecision::Approve { - return Err(MergeRequestError::NotReady(format!( - "Merge Request `{merge_request_id}` lacks an approved integration result" - ))); - } - merged_subjects.push(MergeRequestReviewSubject { - merge_request_id: merge_request_id.clone(), - subject_ref: merge.approved_source_ref.clone(), - }); - requests.push(request); - } - let has_requirement_attestation = requests.iter().any(|request| { - request.thread.iter().rev().any(|event| { - let MergeRequestThreadEvent::Review(review) = event else { - return false; - }; - review.event_id == input.requirement_approval_event_id - && review.decision == ReviewDecision::Approve - && review.ticket_item_revision == input.item_revision - && review.ticket_merge_request_subjects == merged_subjects - && request - .effective_review(&review.subject_ref) - .is_some_and(|effective| effective.event_id == review.event_id) - }) - }); - if !has_requirement_attestation { - return Err(MergeRequestError::NotReady( - "no effective Reviewer approval attests the current Ticket revision and exact merged source set" - .into(), - )); - } - let updated = transaction.execute( - "UPDATE typed_tickets - SET workflow_state='done',workflow_state_explicit=1,updated_at=?3 - WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='inprogress'", - params![ - input.auth.workspace_id, - input.ticket_id, - input.now.to_rfc3339() - ], - )?; - if updated != 1 { - return Err(MergeRequestError::Conflict( - "Ticket state changed during completion".into(), - )); - } - let released = transaction.execute( - "DELETE FROM ticket_current_worker_assignments - WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3", - params![ - input.auth.workspace_id, - input.ticket_id, - input.auth.assignment_id - ], - )?; - if released != 1 { - return Err(MergeRequestError::Unauthorized( - "Ticket completion assignment changed while releasing it".into(), - )); - } - let event = TicketCompletionEvent { - operation_id: input.operation_id, - ticket_id: input.ticket_id.clone(), - item_revision: input.item_revision, - merge_request_ids: input.merge_request_ids, - requirement_approval_event_id: input.requirement_approval_event_id, - completed_by: input.auth.actor(), - created_at: input.now, - }; - ticket_completion_event(&transaction, &input.auth.workspace_id, &event)?; - transaction.commit()?; - Ok(event) - } - pub fn repair_selector_from( &self, i: RepairSelectorFrom, @@ -1764,62 +1769,6 @@ fn ticket_event( Ok(()) } -fn ticket_completion_event( - transaction: &Transaction<'_>, - workspace_id: &str, - event: &TicketCompletionEvent, -) -> Result<(), MergeRequestError> { - let event_index: i64 = transaction.query_row( - "SELECT COALESCE(MAX(event_index),-1)+1 FROM typed_ticket_events WHERE workspace_id=?1 AND ticket_id=?2", - params![workspace_id, event.ticket_id], - |row| row.get(0), - )?; - transaction.execute( - "INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,heading,body) - VALUES(?1,?2,?3,'state_changed',?4,?5,'inprogress','done','Ticket requirements completed',?6)", - params![ - workspace_id, - event.ticket_id, - event_index, - format!( - "worker:{}:{}", - event.completed_by.runtime_id, event.completed_by.worker_id - ), - event.created_at.to_rfc3339(), - format!( - "Current Ticket revision `{}` was approved by Review `{}` and completed with Merge Requests: {}.", - event.item_revision, - event.requirement_approval_event_id, - event.merge_request_ids.join(", ") - ) - ], - )?; - for (key, value) in [ - ("operation_id", event.operation_id.clone()), - ("item_revision", event.item_revision.clone()), - ( - "requirement_approval_event_id", - event.requirement_approval_event_id.clone(), - ), - ( - "merge_request_ids", - serde_json::to_string(&event.merge_request_ids) - .map_err(|error| MergeRequestError::Operation(error.to_string()))?, - ), - ( - "ticket_completion", - serde_json::to_string(event) - .map_err(|error| MergeRequestError::Operation(error.to_string()))?, - ), - ] { - transaction.execute( - "INSERT INTO typed_ticket_event_attributes VALUES(?1,?2,?3,?4,?5)", - params![workspace_id, event.ticket_id, event_index, key, value], - )?; - } - Ok(()) -} - pub fn migrate(c: &Connection) -> Result<(), MergeRequestError> { match schema_state(c)? { SchemaState::Fresh => fresh(c), diff --git a/crates/merge-request/tests/evidence.rs b/crates/merge-request/tests/evidence.rs new file mode 100644 index 000000000..feaf6337d --- /dev/null +++ b/crates/merge-request/tests/evidence.rs @@ -0,0 +1,459 @@ +use chrono::{TimeZone, Utc}; +use merge_request::*; + +fn actor() -> WorkerIdentity { + WorkerIdentity { + runtime_id: "runtime".into(), + worker_id: "reviewer".into(), + } +} + +fn subjects() -> Vec { + vec![ + MergeRequestReviewSubject { + merge_request_id: "MR-1".into(), + subject_ref: "source-one".into(), + }, + MergeRequestReviewSubject { + merge_request_id: "MR-2".into(), + subject_ref: "source-two".into(), + }, + ] +} + +fn add_review( + request: &mut MergeRequest, + event_id: &str, + revision: &str, + snapshot: &[MergeRequestReviewSubject], + decision: ReviewDecision, +) { + let subject = snapshot + .iter() + .find(|subject| subject.merge_request_id == request.merge_request_id) + .unwrap(); + let sequence = request.thread.len() as u64 + 1; + let requested = ReviewRequestedEvent { + event_id: format!("request-{event_id}"), + sequence, + subject_ref: subject.subject_ref.clone(), + ticket_item_revision: revision.into(), + ticket_merge_request_subjects: snapshot.to_vec(), + requested_by: actor(), + reviewer: actor(), + created_at: request.created_at, + }; + let review = ReviewEvent { + event_id: event_id.into(), + sequence: sequence + 1, + request_event_id: requested.event_id.clone(), + subject_ref: requested.subject_ref.clone(), + ticket_item_revision: revision.into(), + ticket_merge_request_subjects: snapshot.to_vec(), + decision, + body: String::new(), + findings: vec![], + reviewer: actor(), + created_at: request.created_at, + }; + request + .thread + .push(MergeRequestThreadEvent::ReviewRequested(requested)); + request.thread.push(MergeRequestThreadEvent::Review(review)); +} + +fn merged_requests() -> Vec { + let now = Utc.with_ymd_and_hms(2026, 10, 8, 12, 0, 0).unwrap(); + let snapshot = subjects(); + snapshot + .iter() + .map(|subject| { + let mut request = MergeRequest { + workspace_id: "W".into(), + merge_request_id: subject.merge_request_id.clone(), + repository_id: subject.merge_request_id.clone(), + state: MergeRequestState::Merged, + selector_from: Some("work/source".into()), + selector_to: "main".into(), + ticket_ids: vec!["T".into()], + created_at: now, + updated_at: now, + thread: vec![], + }; + let approval_id = format!("integration-{}", subject.merge_request_id); + add_review( + &mut request, + &approval_id, + "revision-one", + &snapshot, + ReviewDecision::Approve, + ); + request + .thread + .push(MergeRequestThreadEvent::Merge(MergeEvent { + event_id: format!("merge-{}", subject.merge_request_id), + sequence: 3, + operation_id: format!("operation-{}", subject.merge_request_id), + approval_event_id: approval_id, + approved_source_ref: subject.subject_ref.clone(), + target_ref_before: "target-before".into(), + target_ref_after: "target-after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + merged_by: actor(), + created_at: now, + })); + request + }) + .collect() +} + +#[test] +fn postmerge_latest_approval_attests_the_current_revision_without_replacing_integration() { + let mut requests = merged_requests(); + let snapshot = subjects(); + assert_eq!( + requirement_approval( + &requests, + "revision-two", + &snapshot, + Some("integration-MR-1") + ), + Err(MergeRequestEvidenceError::ItemRevisionMismatch) + ); + add_review( + &mut requests[0], + "postmerge", + "revision-two", + &snapshot, + ReviewDecision::Approve, + ); + + assert_eq!( + requests[0].integration_approval().unwrap().event_id, + "integration-MR-1" + ); + assert_eq!( + requirement_approval(&requests, "revision-two", &snapshot, None) + .unwrap() + .event_id, + "postmerge" + ); + assert_eq!( + requirement_approval(&requests, "revision-two", &snapshot, Some("postmerge")) + .unwrap() + .event_id, + "postmerge" + ); + assert_eq!( + requirement_approval( + &requests, + "revision-one", + &snapshot, + Some("integration-MR-1") + ), + Err(MergeRequestEvidenceError::ApprovalNotEffective) + ); + assert_eq!( + requirement_approval(&requests, "revision-two", &snapshot, Some("unknown")), + Err(MergeRequestEvidenceError::RequirementApprovalMissing) + ); +} + +#[test] +fn later_request_changes_prevents_older_requirement_approval_from_being_effective() { + let mut requests = merged_requests(); + let snapshot = subjects(); + add_review( + &mut requests[0], + "approved", + "revision-two", + &snapshot, + ReviewDecision::Approve, + ); + add_review( + &mut requests[0], + "changes", + "revision-two", + &snapshot, + ReviewDecision::RequestChanges, + ); + assert_eq!( + requirement_approval(&requests, "revision-two", &snapshot, None), + Err(MergeRequestEvidenceError::RequirementApprovalMissing) + ); + assert_eq!( + requirement_approval(&requests, "revision-two", &snapshot, Some("approved")), + Err(MergeRequestEvidenceError::ApprovalNotEffective) + ); + assert_eq!( + requirement_approval(&requests, "revision-two", &snapshot, Some("changes")), + Err(MergeRequestEvidenceError::ApprovalNotApproved) + ); + assert!(requests[0].integration_approval().is_ok()); +} + +#[test] +fn changed_revision_or_any_linked_source_invalidates_a_multiple_mr_attestation() { + let requests = merged_requests(); + let original = subjects(); + assert!(requirement_approval(&requests, "revision-one", &original, None).is_ok()); + assert_eq!( + requirement_approval( + &requests, + "revision-two", + &original, + Some("integration-MR-1") + ), + Err(MergeRequestEvidenceError::ItemRevisionMismatch) + ); + let mut changed = original.clone(); + changed[1].subject_ref = "changed-source-two".into(); + assert_eq!( + requirement_approval( + &requests, + "revision-one", + &changed, + Some("integration-MR-1") + ), + Err(MergeRequestEvidenceError::SourceSnapshotMismatch) + ); + let mut reversed = original.clone(); + reversed.reverse(); + assert!(requirement_approval(&requests, "revision-one", &reversed, None).is_ok()); + + // Even after the caller refreshes the linked request collection, an old + // attestation cannot authorize completion against a smaller linked set. + assert_eq!( + requirement_approval( + &requests[..1], + "revision-one", + &original[..1], + Some("integration-MR-1") + ), + Err(MergeRequestEvidenceError::SourceSnapshotMismatch) + ); + assert_eq!( + requirement_approval(&requests, "revision-one", &original[..1], None), + Err(MergeRequestEvidenceError::SourceSnapshotMismatch) + ); + assert_eq!( + requirement_approval( + &requests, + "revision-one", + &[original[0].clone(), original[0].clone()], + None + ), + Err(MergeRequestEvidenceError::SourceSnapshotMismatch) + ); +} + +#[test] +fn refreshed_postmerge_approval_can_attest_a_changed_multiple_mr_set_and_revision() { + let mut requests = merged_requests(); + let mut snapshot = subjects(); + snapshot.pop(); + requests.pop(); + assert_eq!( + requirement_approval(&requests, "revision-two", &snapshot, None), + Err(MergeRequestEvidenceError::RequirementApprovalMissing) + ); + add_review( + &mut requests[0], + "refreshed-set", + "revision-two", + &snapshot, + ReviewDecision::Approve, + ); + assert_eq!( + requirement_approval(&requests, "revision-two", &snapshot, None) + .unwrap() + .event_id, + "refreshed-set" + ); + assert_eq!( + requests[0].integration_approval().unwrap().event_id, + "integration-MR-1" + ); +} + +#[test] +fn requirement_approval_must_bind_its_own_mr_source_and_corresponding_request() { + let original = subjects(); + for (case, expected) in [ + ("missing", MergeRequestEvidenceError::ReviewRequestMissing), + ("revision", MergeRequestEvidenceError::ReviewRequestMismatch), + ("snapshot", MergeRequestEvidenceError::ReviewRequestMismatch), + ("reviewer", MergeRequestEvidenceError::ReviewRequestMismatch), + ("source", MergeRequestEvidenceError::ApprovalSourceMismatch), + ] { + let mut requests = merged_requests(); + for event in &mut requests[0].thread { + match event { + MergeRequestThreadEvent::Review(review) if case == "missing" => { + review.request_event_id = "absent-request".into(); + } + MergeRequestThreadEvent::ReviewRequested(requested) => match case { + "revision" => requested.ticket_item_revision = "other-revision".into(), + "snapshot" => { + requested.ticket_merge_request_subjects.pop(); + } + "reviewer" => requested.reviewer.worker_id = "other-reviewer".into(), + "source" => requested.subject_ref = "unbound-source".into(), + _ => {} + }, + MergeRequestThreadEvent::Review(review) if case == "source" => { + review.subject_ref = "unbound-source".into(); + } + _ => {} + } + } + assert_eq!( + requirement_approval( + &requests, + "revision-one", + &original, + Some("integration-MR-1") + ), + Err(expected), + "{case}" + ); + } +} + +#[test] +fn revoked_requirement_approval_is_rejected_and_discovery_uses_another_valid_mr() { + let mut requests = merged_requests(); + let created_at = requests[0].created_at; + requests[0] + .thread + .push(MergeRequestThreadEvent::ReviewRevoked(ReviewRevokedEvent { + event_id: "revocation".into(), + sequence: 4, + review_event_id: "integration-MR-1".into(), + subject_ref: "source-one".into(), + reason: String::new(), + revoked_by: actor(), + created_at, + })); + assert_eq!( + requirement_approval( + &requests, + "revision-one", + &subjects(), + Some("integration-MR-1") + ), + Err(MergeRequestEvidenceError::ApprovalRevoked) + ); + assert_eq!( + requirement_approval(&requests, "revision-one", &subjects(), None) + .unwrap() + .event_id, + "integration-MR-2" + ); +} + +#[test] +fn integration_approval_must_strictly_predate_the_merge_sequence() { + for approval_sequence in [2, 3, 4] { + let mut requests = merged_requests(); + for event in &mut requests[0].thread { + if let MergeRequestThreadEvent::Review(review) = event { + review.sequence = approval_sequence; + } + } + let result = requests[0].integration_approval(); + if approval_sequence < 3 { + assert!(result.is_ok()); + } else { + assert_eq!( + result, + Err(MergeRequestEvidenceError::ApprovalTimelineMismatch), + "approval sequence {approval_sequence}" + ); + } + } +} + +#[test] +fn blank_saved_source_is_not_valid_integration_evidence_even_when_the_review_matches() { + for source in ["", " \t\n"] { + let mut requests = merged_requests(); + for event in &mut requests[0].thread { + match event { + MergeRequestThreadEvent::ReviewRequested(requested) => { + requested.subject_ref = source.into(); + } + MergeRequestThreadEvent::Review(review) => { + review.subject_ref = source.into(); + } + MergeRequestThreadEvent::Merge(merge) => { + merge.approved_source_ref = source.into(); + } + _ => {} + } + } + assert_eq!( + requests[0].integration_approval(), + Err(MergeRequestEvidenceError::ApprovalSourceMismatch) + ); + } +} + +#[test] +fn evidence_error_codes_are_stable_machine_diagnostics_separate_from_messages() { + for (error, code) in [ + (MergeRequestEvidenceError::NotMerged, "not_merged"), + ( + MergeRequestEvidenceError::MergeResultMissing, + "merge_result_missing", + ), + ( + MergeRequestEvidenceError::ApprovalMissing, + "approval_missing", + ), + ( + MergeRequestEvidenceError::ApprovalRevoked, + "approval_revoked", + ), + ( + MergeRequestEvidenceError::ApprovalNotApproved, + "approval_not_approved", + ), + ( + MergeRequestEvidenceError::ApprovalSourceMismatch, + "approval_source_mismatch", + ), + ( + MergeRequestEvidenceError::ApprovalTimelineMismatch, + "approval_timeline_mismatch", + ), + ( + MergeRequestEvidenceError::ReviewRequestMissing, + "review_request_missing", + ), + ( + MergeRequestEvidenceError::ReviewRequestMismatch, + "review_request_mismatch", + ), + ( + MergeRequestEvidenceError::ApprovalNotEffective, + "approval_not_effective", + ), + ( + MergeRequestEvidenceError::ItemRevisionMismatch, + "item_revision_mismatch", + ), + ( + MergeRequestEvidenceError::SourceSnapshotMismatch, + "source_snapshot_mismatch", + ), + ( + MergeRequestEvidenceError::RequirementApprovalMissing, + "requirement_approval_missing", + ), + ] { + assert_eq!(error.code(), code); + assert_ne!(error.code(), error.as_str()); + } +} diff --git a/crates/merge-request/tests/store.rs b/crates/merge-request/tests/store.rs index b73f656eb..80b7dd1a9 100644 --- a/crates/merge-request/tests/store.rs +++ b/crates/merge-request/tests/store.rs @@ -39,7 +39,7 @@ fn fixture() -> (tempfile::TempDir, MergeRequestStore) { let d = tempfile::tempdir().unwrap(); let p = d.path().join("db"); let c = Connection::open(&p).unwrap(); - c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');").unwrap(); + c.execute_batch("CREATE TABLE workspaces(workspace_id TEXT PRIMARY KEY);CREATE TABLE repositories(workspace_id TEXT,repository_id TEXT,PRIMARY KEY(workspace_id,repository_id));CREATE TABLE ticket_current_worker_assignments(workspace_id TEXT,ticket_id TEXT,assignment_id TEXT,runtime_id TEXT,worker_id TEXT,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_tickets(workspace_id TEXT,ticket_id TEXT,workflow_state TEXT,workflow_state_explicit INTEGER,updated_at TEXT,PRIMARY KEY(workspace_id,ticket_id));CREATE TABLE typed_ticket_events(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,kind TEXT,author TEXT,at TEXT,from_state TEXT,to_state TEXT,heading TEXT,body TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index));CREATE TABLE typed_ticket_event_attributes(workspace_id TEXT,ticket_id TEXT,event_index INTEGER,key TEXT,value TEXT,PRIMARY KEY(workspace_id,ticket_id,event_index,key));INSERT INTO workspaces VALUES('W');INSERT INTO repositories VALUES('W','R');INSERT INTO repositories VALUES('W','R2');INSERT INTO ticket_current_worker_assignments VALUES('W','T','A','runtime','coder','t');INSERT INTO typed_tickets VALUES('W','T','inprogress',1,'t');CREATE VIEW ticket_active_worker_assignments AS SELECT current.* FROM ticket_current_worker_assignments current JOIN typed_tickets ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id WHERE ticket.workflow_state NOT IN ('done','closed');").unwrap(); drop(c); let a = Assignments(Arc::new(Mutex::new(CurrentAssignment { assignment_id: "A".into(), @@ -166,7 +166,7 @@ fn review_submission_authorization_rejects_invalid_grants_before_side_effects() } #[test] -fn selectors_thread_and_completion_have_no_revision_or_commit_api() { +fn integration_preserves_selectors_ticket_state_and_assignment_and_replays_its_merge() { let (d, s) = fixture(); open(&s); let review = approve(&s, "opaque-source-ref", "token"); @@ -226,43 +226,14 @@ fn selectors_thread_and_completion_have_no_revision_or_commit_api() { }) .unwrap(); assert_eq!(replayed, merged); - let completed = s - .complete_ticket(CompleteTicket { - ticket_id: "T".into(), - operation_id: "ticket-op".into(), - item_revision: "t".into(), - merge_request_ids: vec!["MR".into()], - requirement_approval_event_id: merged.approval_event_id.clone(), - auth: auth(), - now: at(7), - }) - .unwrap(); - assert_eq!(completed.merge_request_ids, vec!["MR"]); let connection = Connection::open(d.path().join("db")).unwrap(); - let (state, assignment_exists): (String, bool) = connection - .query_row( - "SELECT workflow_state, EXISTS( - SELECT 1 FROM ticket_current_worker_assignments - WHERE workspace_id='W' AND ticket_id='T' - ) FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - assert_eq!(state, "done"); - assert!(!assignment_exists); - let replayed_completion = s - .complete_ticket(CompleteTicket { - ticket_id: "T".into(), - operation_id: "ticket-op".into(), - item_revision: "t".into(), - merge_request_ids: vec!["MR".into()], - requirement_approval_event_id: merged.approval_event_id.clone(), - auth: auth(), - now: at(8), - }) - .unwrap(); - assert_eq!(replayed_completion, completed); + let (state, active): (String, bool) = connection.query_row( + "SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ).unwrap(); + assert_eq!(state, "inprogress"); + assert!(active); let json = serde_json::to_string(&mr).unwrap(); for banned in [ "revision_id", @@ -864,12 +835,12 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { let (dir, store) = fixture(); open(&store); let integration_approval = approve(&store, "subject", "token-integration"); - store + let merge = store .complete(CompleteMergeRequest { merge_request_id: "MR".into(), ticket_id: "T".into(), operation_id: "merge".into(), - approval_event_id: integration_approval.event_id, + approval_event_id: integration_approval.event_id.clone(), current_subject_ref: "subject".into(), target_ref_before: "target-before".into(), target_ref_after: "target-after".into(), @@ -894,6 +865,26 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { .unwrap(); drop(connection); + let snapshot = vec![MergeRequestReviewSubject { + merge_request_id: "MR".into(), + subject_ref: merge.approved_source_ref.clone(), + }]; + let before_refresh = store.list_for_ticket("W", "T").unwrap(); + assert_eq!( + requirement_approval( + &before_refresh, + "T:1", + &snapshot, + Some(&integration_approval.event_id), + ), + Err(MergeRequestEvidenceError::ItemRevisionMismatch) + ); + assert_eq!(before_refresh[0].merged_result().unwrap(), &merge); + assert_eq!( + before_refresh[0].integration_approval().unwrap(), + &integration_approval + ); + store .register_reviewer_child_session(RegisterReviewerChildSession { workspace_id: "W".into(), @@ -920,7 +911,7 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { now: at(7), }) .unwrap(); - let requirement_approval = store + let requirement_review = store .submit_review(SubmitMergeRequestReview { merge_request_id: "MR".into(), ticket_id: "T".into(), @@ -932,17 +923,33 @@ fn ticket_rescope_after_integration_accepts_fresh_requirement_attestation() { now: at(8), }) .unwrap(); - store - .complete_ticket(CompleteTicket { - ticket_id: "T".into(), - operation_id: "ticket-complete-post-rescope".into(), - item_revision: "T:1".into(), - merge_request_ids: vec!["MR".into()], - requirement_approval_event_id: requirement_approval.event_id, - auth: auth(), - now: at(9), - }) - .unwrap(); + let refreshed = store.list_for_ticket("W", "T").unwrap(); + assert_eq!( + requirement_approval( + &refreshed, + "T:1", + &snapshot, + Some(&requirement_review.event_id), + ) + .unwrap(), + &requirement_review + ); + assert_eq!( + requirement_approval(&refreshed, "T:1", &snapshot, None).unwrap(), + &requirement_review + ); + assert_eq!(refreshed[0].merged_result().unwrap(), &merge); + assert_eq!( + refreshed[0].integration_approval().unwrap(), + &integration_approval + ); + let connection = Connection::open(dir.path().join("db")).unwrap(); + let merge_payload: String = connection.query_row( + "SELECT payload_json FROM merge_request_thread_events WHERE workspace_id='W' AND merge_request_id='MR' AND kind='merge'", + [], + |row| row.get(0), + ).unwrap(); + assert_eq!(merge_payload, serde_json::to_string(&merge).unwrap()); } #[test] @@ -978,19 +985,19 @@ fn review_request_rejects_a_snapshot_that_omits_a_linked_merge_request() { } #[test] -fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_completion() { +fn partial_and_full_integration_retain_ticket_state_and_active_assignment() { let (dir, store) = fixture(); open_for(&store, "MR", "R"); open_for(&store, "MR-2", "R2"); let first = approve_for(&store, "MR", "R", "subject-one", "token-one"); let second = approve_for(&store, "MR-2", "R2", "subject-two", "token-two"); - store + let first_merge = store .complete(CompleteMergeRequest { merge_request_id: "MR".into(), ticket_id: "T".into(), operation_id: "merge-one".into(), - approval_event_id: first.event_id, + approval_event_id: first.event_id.clone(), current_subject_ref: "subject-one".into(), target_ref_before: "target-one-before".into(), target_ref_after: "target-one-after".into(), @@ -1000,30 +1007,33 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple now: at(5), }) .unwrap(); - let connection = Connection::open(dir.path().join("db")).unwrap(); - let (state, assigned): (String, bool) = connection - .query_row( - "SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", - [], - |row| Ok((row.get(0)?, row.get(1)?)), - ) - .unwrap(); - assert_eq!(state, "inprogress"); - assert!(assigned); - assert!(matches!( - store.complete_ticket(CompleteTicket { - ticket_id: "T".into(), - operation_id: "ticket-complete".into(), - item_revision: "t".into(), - merge_request_ids: vec!["MR".into(), "MR-2".into()], - requirement_approval_event_id: second.event_id.clone(), - auth: auth(), - now: at(6), - }), - Err(MergeRequestError::NotReady(_)) - )); + let partial = store.get_by_id("W", "MR-2").unwrap(); + assert_eq!( + partial.integration_approval(), + Err(MergeRequestEvidenceError::NotMerged) + ); - store + let connection = Connection::open(dir.path().join("db")).unwrap(); + let assert_ticket_unchanged = || { + let (state, assigned, active, state_changes): (String, bool, bool, i64) = connection + .query_row( + "SELECT workflow_state, + EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T'), + EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id='W' AND ticket_id='T'), + (SELECT COUNT(*) FROM typed_ticket_events WHERE workspace_id='W' AND ticket_id='T' AND kind='state_changed') + FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!(state, "inprogress"); + assert!(assigned); + assert!(active); + assert_eq!(state_changes, 0); + }; + assert_ticket_unchanged(); + + let second_merge = store .complete(CompleteMergeRequest { merge_request_id: "MR-2".into(), ticket_id: "T".into(), @@ -1038,63 +1048,194 @@ fn partial_integration_retains_ticket_and_assignment_until_guarded_ticket_comple now: at(7), }) .unwrap(); - assert!(matches!( - store.complete_ticket(CompleteTicket { - ticket_id: "T".into(), - operation_id: "ticket-complete".into(), - item_revision: "stale".into(), - merge_request_ids: vec!["MR".into(), "MR-2".into()], - requirement_approval_event_id: second.event_id.clone(), - auth: auth(), - now: at(8), - }), - Err(MergeRequestError::Conflict(_)) - )); - assert!(matches!( - store.complete_ticket(CompleteTicket { - ticket_id: "T".into(), - operation_id: "ticket-complete-missing-attestation".into(), - item_revision: "t".into(), - merge_request_ids: vec!["MR".into(), "MR-2".into()], - requirement_approval_event_id: "missing-review".into(), - auth: auth(), - now: at(8), - }), - Err(MergeRequestError::NotReady(_)) - )); - assert!(matches!( - store.complete_ticket(CompleteTicket { - ticket_id: "T".into(), - operation_id: "ticket-complete".into(), - item_revision: "t".into(), - merge_request_ids: vec!["MR".into()], - requirement_approval_event_id: second.event_id.clone(), - auth: auth(), - now: at(8), - }), - Err(MergeRequestError::Conflict(_)) - )); - let completed = store - .complete_ticket(CompleteTicket { - ticket_id: "T".into(), - operation_id: "ticket-complete".into(), - item_revision: "t".into(), - merge_request_ids: vec!["MR-2".into(), "MR".into()], - requirement_approval_event_id: second.event_id.clone(), - auth: auth(), - now: at(9), + assert_ticket_unchanged(); + let requests = store.list_for_ticket("W", "T").unwrap(); + let snapshot = requests + .iter() + .map(|request| MergeRequestReviewSubject { + merge_request_id: request.merge_request_id.clone(), + subject_ref: request.merged_result().unwrap().approved_source_ref.clone(), }) + .collect::>(); + assert_eq!( + requirement_approval(&requests, "t", &snapshot, Some(&second.event_id)).unwrap(), + &second + ); + let first_request = requests + .iter() + .find(|request| request.merge_request_id == "MR") .unwrap(); - let replay = store - .complete_ticket(CompleteTicket { - ticket_id: "T".into(), - operation_id: "ticket-complete".into(), - item_revision: "t".into(), - merge_request_ids: vec!["MR".into(), "MR-2".into()], - requirement_approval_event_id: second.event_id.clone(), - auth: auth(), - now: at(10), - }) + let second_request = requests + .iter() + .find(|request| request.merge_request_id == "MR-2") .unwrap(); - assert_eq!(replay, completed); + assert_eq!(first_request.merged_result().unwrap(), &first_merge); + assert_eq!(second_request.merged_result().unwrap(), &second_merge); + assert_eq!(first_request.integration_approval().unwrap(), &first); + assert_eq!(second_request.integration_approval().unwrap(), &second); +} + +#[test] +fn historical_ticket_completion_payload_remains_readable_and_unchanged_on_migration() { + let (dir, _store) = fixture(); + let connection = Connection::open(dir.path().join("db")).unwrap(); + let payload = r#"{ + "operation_id": "historical-operation", + "ticket_id": "T", + "item_revision": "historical-revision", + "merge_request_ids": ["MR-2", "MR"], + "requirement_approval_event_id": "historical-review", + "completed_by": {"runtime_id": "runtime", "worker_id": "coder"}, + "created_at": "2026-07-26T12:00:07Z" + }"#; + connection.execute_batch( + "UPDATE typed_tickets SET workflow_state='done' WHERE workspace_id='W' AND ticket_id='T'; + INSERT INTO typed_ticket_events VALUES('W','T',0,'state_changed','worker:runtime:coder','2026-07-26T12:00:07Z','inprogress','done','Ticket requirements completed','historical body');" + ).unwrap(); + connection + .execute( + "INSERT INTO typed_ticket_event_attributes VALUES('W','T',0,'ticket_completion',?1)", + [payload], + ) + .unwrap(); + + migrate(&connection).unwrap(); + + let (saved_payload, kind, from_state, to_state, heading, body, state): (String, String, String, String, String, String, String) = connection.query_row( + "SELECT attribute.value,event.kind,event.from_state,event.to_state,event.heading,event.body,ticket.workflow_state + FROM typed_ticket_event_attributes attribute + JOIN typed_ticket_events event USING(workspace_id,ticket_id,event_index) + JOIN typed_tickets ticket USING(workspace_id,ticket_id) + WHERE attribute.workspace_id='W' AND attribute.ticket_id='T' AND attribute.key='ticket_completion'", + [], + |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?,row.get(5)?,row.get(6)?)), + ).unwrap(); + assert_eq!(saved_payload, payload); + assert_eq!(kind, "state_changed"); + assert_eq!(from_state, "inprogress"); + assert_eq!(to_state, "done"); + assert_eq!(heading, "Ticket requirements completed"); + assert_eq!(body, "historical body"); + assert_eq!(state, "done"); + let historical: TicketCompletionEvent = serde_json::from_str(&saved_payload).unwrap(); + assert_eq!( + serde_json::to_value(&historical).unwrap(), + serde_json::from_str::(payload).unwrap() + ); +} + +#[test] +fn persisted_integration_evidence_rejects_missing_mismatched_revoked_or_late_approval() { + for (case, expected) in [ + ( + "merge-missing", + MergeRequestEvidenceError::MergeResultMissing, + ), + ( + "approval-missing", + MergeRequestEvidenceError::ApprovalMissing, + ), + ("revoked", MergeRequestEvidenceError::ApprovalRevoked), + ( + "source-mismatch", + MergeRequestEvidenceError::ApprovalSourceMismatch, + ), + ( + "not-approved", + MergeRequestEvidenceError::ApprovalNotApproved, + ), + ( + "request-missing", + MergeRequestEvidenceError::ReviewRequestMissing, + ), + ( + "request-mismatch", + MergeRequestEvidenceError::ReviewRequestMismatch, + ), + ( + "approval-after-merge", + MergeRequestEvidenceError::ApprovalTimelineMismatch, + ), + ] { + let (dir, store) = fixture(); + open(&store); + let review = approve(&store, "merged-source", "integration-token"); + let merge = store + .complete(CompleteMergeRequest { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + operation_id: "merge".into(), + approval_event_id: review.event_id.clone(), + current_subject_ref: "merged-source".into(), + target_ref_before: "before".into(), + target_ref_after: "after".into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: auth(), + now: at(5), + }) + .unwrap(); + let persisted = store.get_by_id("W", "MR").unwrap(); + assert_eq!(persisted.merged_result().unwrap(), &merge); + assert_eq!(persisted.integration_approval().unwrap(), &review); + + let connection = Connection::open(dir.path().join("db")).unwrap(); + match case { + "merge-missing" | "approval-missing" | "request-missing" => { + let kind = match case { + "merge-missing" => "merge", + "approval-missing" => "review", + _ => "review_requested", + }; + connection.execute( + "DELETE FROM merge_request_thread_events WHERE workspace_id='W' AND merge_request_id='MR' AND kind=?1", + [kind], + ).unwrap(); + } + "revoked" => { + store + .revoke_review(RevokeMergeRequestReview { + merge_request_id: "MR".into(), + ticket_id: "T".into(), + review_event_id: review.event_id.clone(), + reason: "invalid integration evidence".into(), + auth: auth(), + now: at(6), + }) + .unwrap(); + } + "approval-after-merge" => { + let mut altered = review.clone(); + altered.sequence = merge.sequence + 1; + connection.execute( + "UPDATE merge_request_thread_events SET payload_json=?1,sequence=?2 WHERE workspace_id='W' AND merge_request_id='MR' AND kind='review'", + rusqlite::params![serde_json::to_string(&altered).unwrap(), altered.sequence], + ).unwrap(); + } + _ => { + let mut altered = review.clone(); + match case { + "source-mismatch" => altered.subject_ref = "different-source".into(), + "not-approved" => altered.decision = ReviewDecision::RequestChanges, + "request-mismatch" => { + altered.ticket_item_revision = "different-revision".into() + } + _ => unreachable!(), + } + connection.execute( + "UPDATE merge_request_thread_events SET payload_json=?1 WHERE workspace_id='W' AND merge_request_id='MR' AND kind='review'", + [serde_json::to_string(&altered).unwrap()], + ).unwrap(); + } + } + let reloaded = store.get_by_id("W", "MR").unwrap(); + assert_eq!(reloaded.integration_approval(), Err(expected), "{case}"); + let (state, assigned): (String, bool) = connection.query_row( + "SELECT workflow_state, EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE workspace_id='W' AND ticket_id='T') FROM typed_tickets WHERE workspace_id='W' AND ticket_id='T'", + [], + |row| Ok((row.get(0)?, row.get(1)?)), + ).unwrap(); + assert_eq!(state, "inprogress", "{case}"); + assert!(assigned, "{case}"); + } } diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 65c717d8b..85924f165 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -324,7 +324,6 @@ impl_openapi_schema!( SubmitMergeRequestReviewRequest, TextResponse, TicketCloseRecordRequest, - TicketCompletionEvent, TicketDependencyCheckResponse, TicketDetail, TicketDoctorResponse, @@ -350,6 +349,7 @@ impl_openapi_schema!( TicketRoleAssignmentsResponse, TicketShowRequest, TicketStateChangeRequest, + TicketStateUpdateRequest, TicketSummarySearchQuery, TicketThreadEventRequest, RuntimeManagementApiError, @@ -2917,7 +2917,7 @@ 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, 500], bearer_auth = true, browser_auth = true)] + #[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, @@ -2933,7 +2933,7 @@ 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, 500], bearer_auth = true, browser_auth = true)] + #[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, @@ -2942,7 +2942,7 @@ pub trait ServerApi { #[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, 500], bearer_auth = true, browser_auth = true)] + #[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, @@ -2950,6 +2950,14 @@ pub trait ServerApi { #[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, @@ -3047,15 +3055,15 @@ 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, 500], openapi = false)] + #[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( &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, 500], bearer_auth = true, browser_auth = true)] + ) -> 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, @@ -3150,9 +3158,10 @@ 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, 500], bearer_auth = true, browser_auth = true)] + #[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, @@ -3161,6 +3170,7 @@ pub trait ServerApi { #[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, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] id: String, #[body] request: TicketMarkReadyRequest, @@ -3177,14 +3187,16 @@ pub trait ServerApi { #[post("/api/w/{workspace_id}/tickets/{id}/queue", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_queue( &self, + #[extension] context: ServerRequestContext, #[path] workspace_id: String, #[path] id: String, #[body] request: BrowserQueueTicketRequest, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + #[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, @@ -4239,22 +4251,27 @@ pub struct InitialRepositoryIntent { pub default_ref: Option, } -/// Request for atomically creating a Workspace and its initial Repository. +/// Request for atomically creating a Workspace with an optional initial Repository. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct WorkspaceCreateRequest { pub operation_key: String, pub display_name: String, - pub repository: InitialRepositoryIntent, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub repository: Option, } -/// Response returned after atomically creating a Workspace and its first Repository. +/// Response returned after atomically creating a Workspace. +/// +/// On replay, Workspace/config and the Repository at the originally requested key are +/// current authority, not historical snapshots. Repository is null when creation omitted +/// it or that key has since been removed/renamed; replay never recreates an asset. #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct WorkspaceCreateResponse { pub workspace: WorkspaceSummary, - pub repository: WorkspaceRepositoryRecord, + pub repository: Option, #[cfg_attr(feature = "typescript", ts(type = "number"))] #[schemars(range(min = 0, max = 9_007_199_254_740_991_u64))] pub config_revision: u64, @@ -7404,6 +7421,15 @@ pub struct TicketActionEligibility { pub blockers: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(tag = "status", rename_all = "snake_case", deny_unknown_fields)] +pub enum TicketSourceRefObservation { + Observed {}, + Unavailable { code: String }, + NotRequired {}, +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -7416,6 +7442,8 @@ pub struct TicketMergeRequestSummary { pub selector_to: String, pub updated_at: String, pub current_subject_ref: Option, + pub source_ref_observation: TicketSourceRefObservation, + pub integration_evidence_error: Option, pub review_subject_ref: Option, pub review_requested_at: Option, pub review_submitted_at: Option, @@ -7636,9 +7664,10 @@ 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::TicketStateChange); +transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateUpdate); +transparent_ticket_dto!(TicketStateUpdateRequest, ticket::TicketStateUpdate); transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary); -transparent_ticket_dto!(TicketCloseRecordRequest, ticket::MarkdownText); +transparent_ticket_dto!(TicketCloseRecordRequest, ticket::TicketCompletion); transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation); transparent_ticket_dto!( CreateTicketOrchestrationPlanRequest, @@ -7763,9 +7792,11 @@ pub struct BrowserEditTicketRequest { #[serde(deny_unknown_fields)] pub struct BrowserTransitionTicketStateRequest { pub state: BrowserTicketWorkflowState, - pub reason: Option, + pub operation_key: String, + pub expected_item_revision: String, + pub expected_state: BrowserTicketWorkflowState, + pub reason: String, pub body: Option, - pub author: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7796,6 +7827,9 @@ pub struct BrowserQueueTicketRequest {} #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct BrowserCloseTicketRequest { + pub operation_key: String, + pub expected_item_revision: String, + pub expected_state: BrowserTicketWorkflowState, pub resolution: String, } @@ -8174,22 +8208,12 @@ pub struct CompleteMergeRequestRequest { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct CompleteTicketRequest { - pub operation_id: String, - pub item_revision: String, - pub merge_request_ids: Vec, - pub requirement_approval_event_id: String, -} - -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TicketCompletionEvent { - pub operation_id: String, - pub ticket_id: String, - pub item_revision: String, - pub merge_request_ids: Vec, - pub requirement_approval_event_id: String, - pub completed_by: MergeRequestWorkerIdentity, - pub created_at: String, + pub operation_key: String, + pub expected_item_revision: String, + pub expected_state: ticket::TicketWorkflowState, + pub reason: String, + #[serde(default)] + pub references: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] @@ -11450,13 +11474,6 @@ mod tests { #[test] fn ticket_objective_and_merge_request_operations_are_in_server_api_metadata() { let operations = ServerApiMetadata::OPERATIONS; - assert_eq!( - operations - .iter() - .filter(|operation| operation.operation_id.starts_with("ticket_")) - .count(), - 38 - ); assert_eq!( operations .iter() @@ -11472,6 +11489,16 @@ mod tests { 11 ); for (operation_id, method, path) in [ + ( + "ticket_state_update", + HttpMethod::Post, + "/api/w/{workspace_id}/tickets/{id}/state-update", + ), + ( + "ticket_complete", + HttpMethod::Post, + "/api/w/{workspace_id}/tickets/{id}/complete", + ), ( "ticket_relation_query", HttpMethod::Post, @@ -12193,12 +12220,27 @@ mod tests { let request = WorkspaceCreateRequest { operation_key: "workspace-create-1".to_string(), display_name: "Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { repository_key: "main".to_string(), uri: "/srv/repositories/main".to_string(), default_ref: Some("develop".to_string()), - }, + }), }; + for repository in [None, Some(serde_json::Value::Null)] { + let mut wire = + serde_json::json!({"operation_key": "empty", "display_name": "Workspace"}); + if let Some(repository) = repository { + wire["repository"] = repository; + } + let decoded: WorkspaceCreateRequest = serde_json::from_value(wire).unwrap(); + assert!(decoded.repository.is_none()); + assert!( + serde_json::to_value(decoded) + .unwrap() + .get("repository") + .is_none() + ); + } let json = serde_json::to_value(&request).unwrap(); assert_eq!(json["operation_key"], "workspace-create-1"); assert_eq!(json["repository"]["uri"], "/srv/repositories/main"); @@ -12529,6 +12571,34 @@ mod tests { assert!(serde_json::from_value::(unknown).is_err()); } + #[test] + fn ticket_completion_wire_requires_cas_and_judgment_not_merge_evidence() { + let request = serde_json::json!({ + "operation_key": "investigation-done", + "expected_item_revision": "ticket:1", + "expected_state": "planning", + "reason": "Question answered in the thread" + }); + let parsed: CompleteTicketRequest = serde_json::from_value(request.clone()).unwrap(); + assert!(parsed.references.is_empty()); + for field in [ + "operation_key", + "expected_item_revision", + "expected_state", + "reason", + ] { + let mut missing = request.clone(); + missing.as_object_mut().unwrap().remove(field); + assert!( + 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()); + } + #[cfg(feature = "typescript")] #[test] fn generated_catalog_typescript_keeps_public_wrappers_and_nullability() { @@ -13377,7 +13447,6 @@ mod openapi_artifact_tests { "merge_request_review_revoke", "merge_request_review_submit", "merge_request_reviewer_child_register", - "ticket_complete", "runtime_resource_fetch", "worker_control_cancel", "worker_control_input", diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index 8f41eecc8..c2d86bff7 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -89,6 +89,8 @@ pub enum TicketError { InvalidReadWriteTargetCount(usize), #[error("ticket target authority is unavailable")] TargetAuthorityUnavailable, + #[error("stale ticket item revision: expected `{expected}`, found `{actual}`")] + StaleItemRevision { expected: String, actual: String }, #[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")] StaleWorkflowState { expected: String, actual: String }, #[error("invalid ticket workflow transition `{from}` -> `{to}`")] @@ -282,18 +284,13 @@ impl TicketWorkflowState { from == Self::Planning && to == Self::Ready } - pub fn is_queue_transition(from: Self, to: Self) -> bool { - from == Self::Ready && to == Self::Queued + 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 { - matches!( - (from, to), - (Self::Queued, Self::InProgress) - | (Self::InProgress, Self::Done) - | (Self::Ready, Self::Planning) - | (Self::Queued, Self::Planning) - ) + from != to } } @@ -473,6 +470,66 @@ impl TicketStateChange { } } +/// Audited completion independent of repositories, Git, or Merge Requests. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketCompletion { + pub expected_item_revision: String, + pub expected_state: TicketWorkflowState, + pub operation_key: String, + pub reason: String, + #[serde(default)] + pub references: Vec, + #[serde(default)] + pub author: Option, +} + +/// External state mutation with item/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_item_revision: 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, +} + +impl From for TicketStateUpdate { + fn from(request: TicketCompletion) -> Self { + Self { + expected_item_revision: request.expected_item_revision, + expected_state: request.expected_state, + state: TicketWorkflowState::Done, + operation_key: request.operation_key, + reason: request.reason, + references: request.references, + author: request.author, + } + } +} + +/// Canonical item revision shared by Ticket authority, item checkers, and Merge +/// Request review records. Only persisted item_edit event indexes advance it. +pub fn ticket_item_revision(ticket: &Ticket) -> String { + let index = ticket + .events + .iter() + .filter(|event| event.kind == TicketEventKind::Other("item_edit".to_owned())) + .filter_map(|event| event.attributes.get("event_sequence")?.parse::().ok()) + .max() + .unwrap_or(0); + format!("{}:{index}", ticket.meta.id) +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] pub struct TicketIntakeSummary { pub author: Option, @@ -585,7 +642,7 @@ pub enum TicketTargetsEdit { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct ResolvedTicketTarget { pub repository_key: String, - pub ref_selector: String, + pub ref_selector: Option, pub access: TicketTargetAccess, } @@ -593,7 +650,8 @@ pub struct ResolvedTicketTarget { /// /// Ticket storage never infers repositories from cwd or repository paths. The /// Workspace Backend supplies this boundary from its authoritative repository -/// catalog. Backends without it fail closed for ready/queue transitions. +/// catalog, validating repository identity and selector syntax without Git +/// resolution. Empty targets are valid in every Ticket state. pub trait TicketTargetAuthority: Send + Sync { fn resolve_target( &self, @@ -645,12 +703,8 @@ fn resolve_ready_targets( targets: &[TicketTarget], ) -> Result> { validate_ticket_targets(targets)?; - let read_write_count = targets - .iter() - .filter(|target| target.access == TicketTargetAccess::ReadWrite) - .count(); - if read_write_count == 0 { - return Err(TicketError::InvalidReadWriteTargetCount(read_write_count)); + if targets.is_empty() { + return Ok(Vec::new()); } let authority = authority.ok_or(TicketError::TargetAuthorityUnavailable)?; let mut resolved = Vec::with_capacity(targets.len()); @@ -662,7 +716,9 @@ fn resolve_ready_targets( target.ref_selector.as_deref(), )?; validate_required_event_value("repository_key", &canonical.repository_key)?; - validate_required_event_value("ref_selector", &canonical.ref_selector)?; + if let Some(selector) = canonical.ref_selector.as_deref() { + validate_required_event_value("ref_selector", selector)?; + } canonical.access = target.access; if !repositories.insert(canonical.repository_key.clone()) { return Err(TicketError::DuplicateTargetRepository( @@ -679,7 +735,7 @@ fn canonical_ticket_targets(targets: &[ResolvedTicketTarget]) -> Vec Result<()> { - if current == TicketWorkflowState::Planning && to == TicketWorkflowState::Ready - || current == TicketWorkflowState::Planning && to == TicketWorkflowState::InProgress - || current == TicketWorkflowState::Ready && to == TicketWorkflowState::Queued - || current == TicketWorkflowState::Ready && to == TicketWorkflowState::InProgress - { - return Err(TicketError::InvalidWorkflowTransition { - from: current.as_str().to_owned(), - to: to.as_str().to_owned(), - }); - } - if !TicketWorkflowState::is_role_transition(current, to) { + if current == to { return Err(TicketError::InvalidWorkflowTransition { from: current.as_str().to_owned(), to: to.as_str().to_owned(), @@ -1260,72 +1318,14 @@ pub fn project_ticket_workspace_item( } pub fn ticket_queue_guard( - summary: &TicketSummary, + _summary: &TicketSummary, relation_blockers: &[TicketRelationBlocker], - orchestration_overlay: Option<&TicketWorkspaceStateOverlay>, + _orchestration_overlay: Option<&TicketWorkspaceStateOverlay>, ) -> TicketQueueGuard { - if orchestration_overlay.is_some() { - return TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some( - "orchestration overlay already shows progress; duplicate queue is suppressed" - .to_string(), - ), - blocked_reason: None, - }; - } - if summary.workflow_state != TicketWorkflowState::Ready { - return TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some(format!( - "Ticket state is {}; only ready Tickets can be queued for Orchestrator", - summary.workflow_state.as_str() - )), - blocked_reason: None, - }; - } - if relation_blockers - .iter() - .any(|blocker| blocker.blocking_ticket == summary.id) - { - return TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Dependency cycle must be resolved before Queue".to_string()), - blocked_reason: Some(format!( - "Ticket {} is part of a dependency cycle", - summary.id - )), - }; - } - if relation_blockers - .iter() - .any(|blocker| blocker.blocking_state == TicketWorkflowState::Planning) - { - let blocked_reason = relation_blockers - .iter() - .filter(|blocker| blocker.blocking_state == TicketWorkflowState::Planning) - .map(|blocker| { - format!( - "{} ({})", - blocker.blocking_ticket, - blocker.blocking_state.as_str() - ) - }) - .collect::>() - .join(", "); - return TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Dependencies must leave planning before Queue can proceed".to_string()), - blocked_reason: Some(blocked_reason), - }; - } TicketQueueGuard { can_queue_for_orchestrator: true, - reason: (!relation_blockers.is_empty()).then(|| { - "Ready dependencies will be queued atomically; active dependencies remain unchanged" - .to_string() - }), - blocked_reason: None, + reason: Some("Queue submits only the explicitly requested Ticket; state and dependencies are diagnostic context".to_owned()), + blocked_reason: (!relation_blockers.is_empty()).then(|| format_relation_blockers(relation_blockers)), } } @@ -1333,177 +1333,24 @@ fn derive_ticket_workspace_projection( summary: &TicketSummary, relation_blockers: &[TicketRelationBlocker], ) -> TicketWorkspaceProjection { - if !relation_blockers.is_empty() { - let active_blockers = relation_blockers - .iter() - .filter(|blocker| { - blocker.blocking_ticket == summary.id - || !relation_blocker_allows_ready_queue(blocker) - }) - .collect::>(); - if summary.workflow_state != TicketWorkflowState::Ready || !active_blockers.is_empty() { - let blockers_to_report = if active_blockers.is_empty() { - relation_blockers.iter().collect::>() - } else { - active_blockers - }; - let blockers = format_workspace_relation_blockers(&blockers_to_report); - let waiting_reason = format!("waiting for {blockers}"); - return TicketWorkspaceProjection { - kind: workspace_row_kind_for_state(summary.workflow_state), - priority: match summary.workflow_state { - TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { - TicketWorkspaceActionPriority::ActiveWork - } - _ => TicketWorkspaceActionPriority::Background, - }, - next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), - visible_state: summary.workflow_state.as_str().to_string(), - visible_overlay: None, - disabled_reason: Some(format!( - "Dependency context: {waiting_reason}. The Orchestrator decides whether work waits or starts in parallel." - )), - key_hint: Some(format!("Dependencies: {waiting_reason}")), - blocked_reason: Some(blockers), - queue_guard: TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some(waiting_reason), - blocked_reason: None, - }, - }; - } - - let blockers = format_workspace_relation_blockers( - &relation_blockers - .iter() - .collect::>(), - ); - let mut queue_targets = vec![summary.id.clone()]; - queue_targets.extend( - relation_blockers - .iter() - .filter(|blocker| blocker.blocking_state == TicketWorkflowState::Ready) - .map(|blocker| blocker.blocking_ticket.clone()), - ); - queue_targets.sort(); - queue_targets.dedup(); - return TicketWorkspaceProjection { - kind: TicketWorkspaceRowKind::Ticket, - priority: TicketWorkspaceActionPriority::ReadyForQueue, - next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator), - visible_state: summary.workflow_state.as_str().to_string(), - visible_overlay: None, - disabled_reason: None, - key_hint: Some(format!( - "Queue targets: {}; active dependencies remain orchestration context ({blockers}).", - queue_targets.join(", ") - )), - blocked_reason: Some(blockers), - queue_guard: TicketQueueGuard { - can_queue_for_orchestrator: true, - reason: None, - blocked_reason: None, - }, - }; - } - - match summary.workflow_state { - TicketWorkflowState::Ready => TicketWorkspaceProjection { - kind: TicketWorkspaceRowKind::Ticket, - priority: TicketWorkspaceActionPriority::ReadyForQueue, - next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator), - visible_state: summary.workflow_state.as_str().to_string(), - visible_overlay: None, - disabled_reason: None, - key_hint: Some( - "Queue transitions ready -> queued and may notify Orchestrator".to_string(), - ), - blocked_reason: None, - queue_guard: TicketQueueGuard { - can_queue_for_orchestrator: true, - reason: None, - blocked_reason: None, - }, - }, - TicketWorkflowState::Queued => TicketWorkspaceProjection { - kind: TicketWorkspaceRowKind::ActiveWork, - priority: TicketWorkspaceActionPriority::ActiveWork, - next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), - visible_state: summary.workflow_state.as_str().to_string(), - visible_overlay: None, - disabled_reason: Some("Ticket is queued for Orchestrator routing.".to_string()), - key_hint: None, - blocked_reason: None, - queue_guard: TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Ticket is already queued for Orchestrator routing".to_string()), - blocked_reason: None, - }, - }, - TicketWorkflowState::InProgress => TicketWorkspaceProjection { - kind: TicketWorkspaceRowKind::ActiveWork, - priority: TicketWorkspaceActionPriority::ActiveWork, - next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), - visible_state: summary.workflow_state.as_str().to_string(), - visible_overlay: None, - disabled_reason: Some("Ticket is already in progress.".to_string()), - key_hint: None, - blocked_reason: None, - queue_guard: TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Ticket is already in progress".to_string()), - blocked_reason: None, - }, - }, - TicketWorkflowState::Done => TicketWorkspaceProjection { - kind: TicketWorkspaceRowKind::Review, - priority: TicketWorkspaceActionPriority::Background, - next_action: Some(TicketWorkspaceNextAction::Close), - visible_state: summary.workflow_state.as_str().to_string(), - visible_overlay: None, - disabled_reason: Some( - "state is done; close if a resolution is still missing.".to_string(), - ), - key_hint: None, - blocked_reason: None, - queue_guard: TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Ticket is done; close or review instead of queueing".to_string()), - blocked_reason: None, - }, - }, - TicketWorkflowState::Planning => TicketWorkspaceProjection { - kind: TicketWorkspaceRowKind::Planning, - priority: TicketWorkspaceActionPriority::Background, - next_action: Some(TicketWorkspaceNextAction::Clarify), - visible_state: summary.workflow_state.as_str().to_string(), - visible_overlay: None, - disabled_reason: Some( - "Ticket is still in planning; mark it ready before queueing.".to_string(), - ), - key_hint: Some("Planning/Intake helpers can set state = ready".to_string()), - blocked_reason: None, - queue_guard: TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Ticket is still in planning".to_string()), - blocked_reason: None, - }, - }, - TicketWorkflowState::Closed => TicketWorkspaceProjection { - kind: TicketWorkspaceRowKind::Review, - priority: TicketWorkspaceActionPriority::Background, - next_action: Some(TicketWorkspaceNextAction::WaitForOrchestrator), - visible_state: summary.workflow_state.as_str().to_string(), - visible_overlay: None, - disabled_reason: Some("Ticket is closed.".to_string()), - key_hint: None, - blocked_reason: None, - queue_guard: TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Ticket is closed".to_string()), - blocked_reason: None, - }, + 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), + visible_state: summary.workflow_state.to_string(), + visible_overlay: None, + disabled_reason: None, + key_hint: Some(format!("Queue only {} for Orchestrator", summary.id)), + blocked_reason: (!relation_blockers.is_empty()) + .then(|| format_relation_blockers(relation_blockers)), + queue_guard: ticket_queue_guard(summary, relation_blockers, None), } } @@ -1601,35 +1448,6 @@ fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str { } } -fn relation_blocker_allows_ready_queue(blocker: &TicketRelationBlocker) -> bool { - matches!( - blocker.blocking_state, - TicketWorkflowState::Ready | TicketWorkflowState::Queued | TicketWorkflowState::InProgress - ) -} - -fn format_workspace_relation_blockers(blockers: &[&TicketRelationBlocker]) -> String { - let shown_blockers = blockers.iter().take(3).count(); - let mut formatted = blockers - .iter() - .take(3) - .map(|blocker| { - format!( - "{} via {} (state: {})", - blocker.blocking_ticket, - blocker.reason_kind, - blocker.blocking_state.as_str() - ) - }) - .collect::>() - .join(", "); - let remaining_blockers = blockers.len().saturating_sub(shown_blockers); - if remaining_blockers > 0 { - formatted.push_str(&format!(" (+{remaining_blockers} more)")); - } - formatted -} - #[derive( Debug, Clone, @@ -1935,6 +1753,14 @@ pub trait TicketBackend { 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 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<()>; @@ -2011,6 +1837,14 @@ pub enum TicketBackendOperation { id: TicketIdOrSlug, change: TicketStateChange, }, + UpdateState { + ticket: String, + request: TicketStateUpdate, + }, + Complete { + ticket: String, + request: TicketCompletion, + }, MarkReady { id: TicketIdOrSlug, request: TicketMarkReady, @@ -2116,6 +1950,12 @@ where 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::MarkReady { id, request } => { TicketBackendOperationResult::Ticket(backend.mark_ready(id, request)?) } @@ -2512,6 +2352,203 @@ impl SqliteTicketBackend { finish_sqlite_transaction(&conn, op(&conn)) } + /// The server supplies these attributes from authenticated runtime context. + /// The live assignment lookup must stay inside the mutation's write transaction: + /// an earlier server precheck alone cannot fence a concurrent release. + fn require_active_source_assignment(&self, conn: &Connection, ticket_id: &str) -> Result<()> { + if self + .event_attributes + .get("source_actor_role") + .map(String::as_str) + != Some("coder") + && !self.event_attributes.contains_key("source_assignment_id") + { + return Ok(()); + } + let required = |key: &str| { + self.event_attributes + .get(key) + .filter(|value| !value.trim().is_empty()) + .ok_or_else(|| { + TicketError::Conflict(format!( + "Ticket mutation requires authenticated {key} for an active assignment" + )) + }) + }; + let runtime_id = required("source_runtime_id")?; + let worker_id = required("source_worker_id")?; + let assignment_id = required("source_assignment_id")?; + let active = conn.query_row( + "SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2 AND runtime_id = ?3 AND worker_id = ?4 AND assignment_id = ?5 LIMIT 1", + params![self.workspace_id, ticket_id, runtime_id, worker_id, assignment_id], + |_| Ok(()), + ).optional().map_err(sqlite_err)?.is_some(); + if !active { + return Err(TicketError::Conflict("Ticket mutation source assignment is no longer active for this exact runtime/worker/Ticket identity".to_owned())); + } + 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.v1\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( + &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_item_revision", &request.expected_item_revision)?; + 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-v1") + || 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); + } + // 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)?; + let actual = ticket_item_revision(&previous); + if actual != request.expected_item_revision { + return Err(TicketError::StaleItemRevision { + expected: request.expected_item_revision.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-v1".to_owned(), + ), + ( + "expected_item_revision".to_owned(), + request.expected_item_revision.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) + } + fn with_read(&self, op: impl FnOnce(&Connection) -> Result) -> Result { let conn = self.open_connection()?; op(&conn) @@ -3223,15 +3260,6 @@ impl TicketBackend for SqliteTicketBackend { } let ticket_id = self.resolve_ticket_id(conn, id)?; let previous = self.load_ticket(conn, &ticket_id)?; - if edit.targets.is_some() { - let current = previous.meta.workflow_state; - if current != TicketWorkflowState::Planning { - return Err(TicketError::Conflict(format!( - "ticket implementation target is locked after planning (current state: {})", - current.as_str() - ))); - } - } let now = now_utc(); let mut body_edit_audit = TicketBodyEditAudit::None; if let Some(title) = edit.title.as_ref() { @@ -3319,45 +3347,8 @@ impl TicketBackend for SqliteTicketBackend { let blockers = transitive_dependency_blockers(&ticket_id, &states, &relations)?; let summary = ticket_summary_from_meta(ticket.meta); let mut projection = project_ticket_workspace_item(&summary, &blockers, None); - let queue_tickets = if summary.workflow_state == TicketWorkflowState::Ready { - match dependency_queue_plan(&ticket_id, &states, &relations) { - Ok(queue_tickets) => { - let target_error = queue_tickets.iter().find_map(|candidate| { - self.load_ticket(conn, candidate) - .and_then(|ticket| { - resolve_ready_targets( - self.target_authority.as_ref(), - &self.workspace_id, - &ticket.meta.targets, - ) - }) - .err() - }); - if let Some(error) = target_error { - projection.queue_guard = TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some( - "Queue dependency target validation failed".to_string(), - ), - blocked_reason: Some(error.to_string()), - }; - Vec::new() - } else { - queue_tickets - } - } - Err(error) => { - projection.queue_guard = TicketQueueGuard { - can_queue_for_orchestrator: false, - reason: Some("Queue dependency validation failed".to_string()), - blocked_reason: Some(error.to_string()), - }; - Vec::new() - } - } - } else { - Vec::new() - }; + projection.queue_guard = ticket_queue_guard(&summary, &blockers, None); + let queue_tickets = vec![ticket_id]; Ok(TicketDependencyCheck { ticket: summary, blockers, @@ -3430,14 +3421,18 @@ impl TicketBackend for SqliteTicketBackend { fn set_state_field( &self, id: TicketIdOrSlug, - _field: &str, + field: &str, change: TicketStateChange, ) -> Result<()> { + if field != "state" { + return Err(TicketError::Conflict(format!( + "unsupported Ticket state field: {field}" + ))); + } self.set_workflow_state(id, change) } fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { - validate_state_change(&change)?; let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| { TicketError::InvalidWorkflowTransition { from: change.from.clone(), @@ -3453,29 +3448,27 @@ impl TicketBackend for SqliteTicketBackend { validate_generic_state_change(from, to)?; self.with_write(|conn| { let ticket_id = self.resolve_ticket_id(conn, id)?; - let current = self.load_ticket(conn, &ticket_id)?.meta.workflow_state; - if current != from { - return Err(TicketError::StaleWorkflowState { - expected: from.as_str().to_owned(), - actual: current.as_str().to_owned(), - }); - } - if from == TicketWorkflowState::Queued && to == TicketWorkflowState::InProgress { - let ticket = self.load_ticket(conn, &ticket_id)?; - let blockers = ticket - .relations - .blockers - .into_iter() - .filter(|blocker| !relation_blocker_allows_queue(blocker)) - .collect::>(); - if !blockers.is_empty() { - return Err(TicketError::BlockingRelations(format_relation_blockers(&blockers))); - } - } - let at = now_utc(); - self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::StateChanged, author: Some(change.author.clone().unwrap_or_else(default_author)), at: Some(at.clone()), status: None, from: Some(change.from), to: Some(change.to), reason: Some(change.reason), state_field: Some("state".to_string()), heading: Some(TicketEventKind::StateChanged.heading()), body: change.body, references: change.references, attributes: BTreeMap::new() })?; - conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, updated_at = ?4, status = CASE WHEN ?3 = 'closed' THEN 'closed' ELSE status END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, to.as_str(), at]).map_err(sqlite_err)?; - Ok(()) + let previous = self.load_ticket(conn, &ticket_id)?; + let request = TicketStateUpdate { + expected_item_revision: ticket_item_revision(&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) }) } @@ -3487,6 +3480,7 @@ impl TicketBackend for SqliteTicketBackend { if validate_mark_ready_replay(&ticket, &request)? { return Ok(ticket); } + self.require_active_source_assignment(conn, &ticket_id)?; if ticket.meta.workflow_state != TicketWorkflowState::Planning { return Err(TicketError::StaleWorkflowState { expected: TicketWorkflowState::Planning.as_str().to_owned(), @@ -3506,7 +3500,7 @@ impl TicketBackend for SqliteTicketBackend { format!( "{}@{} ({})", target.repository_key, - target.ref_selector, + target.ref_selector.as_deref().unwrap_or("(unspecified)"), target.access.as_str() ) }) @@ -3590,99 +3584,67 @@ impl TicketBackend for SqliteTicketBackend { validate_required_event_value("queued_by", queued_by)?; self.with_write(|conn| { let requested_ticket = self.resolve_ticket_id(conn, id)?; - let states = self.state_index(conn)?; - let relations = self.all_relations(conn)?; - let queued_tickets = dependency_queue_plan(&requested_ticket, &states, &relations)?; - if let Some(json) = self - .event_attributes - .get("queue_orchestrator_assignments") - { - let assignments = serde_json::from_str::>(json).map_err( - |error| { - TicketError::Conflict(format!( - "invalid Queue assignment fence: {error}" - )) - }, - )?; - let planned = assignments.keys().cloned().collect::>(); - let actual = queued_tickets.iter().cloned().collect::>(); - if planned != actual || assignments.values().any(|value| value.trim().is_empty()) { - return Err(TicketError::Conflict( - "Queue dependency plan changed after assignment validation".to_string(), - )); + self.require_active_source_assignment(conn, &requested_ticket)?; + let previous = self.load_ticket(conn, &requested_ticket)?; + let mut attributes = BTreeMap::from([ + ("queue_root_ticket".to_owned(), requested_ticket.clone()), + ("queued_by".to_owned(), queued_by.to_owned()), + ]); + if let Some(json) = self.event_attributes.get("queue_orchestrator_assignments") { + let assignments = serde_json::from_str::>(json) + .map_err(|error| TicketError::Conflict(format!("invalid Queue assignment fence: {error}")))?; + if assignments.len() != 1 || assignments.get(&requested_ticket).is_none_or(|value| value.trim().is_empty()) { + return Err(TicketError::Conflict("Queue assignment must name only the explicit Ticket".to_owned())); } + attributes.insert("orchestrator_assignment_id".to_owned(), assignments[&requested_ticket].clone()); } - - let mut resolved_targets = Vec::with_capacity(queued_tickets.len()); - for ticket_id in &queued_tickets { - let ticket = self.load_ticket(conn, ticket_id)?; - let targets = resolve_ready_targets( - self.target_authority.as_ref(), - &self.workspace_id, - &ticket.meta.targets, - )?; - resolved_targets.push((ticket_id.clone(), targets)); - } - let at = now_utc(); - for (ticket_id, targets) in resolved_targets { - let updated = conn.execute( - "UPDATE typed_tickets SET workflow_state = 'queued', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'ready'", - params![self.workspace_id, ticket_id, queued_by, at], - ).map_err(sqlite_err)?; - if updated != 1 { - return Err(TicketError::Conflict(format!( - "Ticket {ticket_id} changed while the dependency queue plan was being applied" - ))); - } - self.replace_ticket_targets( - conn, - &ticket_id, - &canonical_ticket_targets(&targets), - )?; - let mut attributes = BTreeMap::from([ - ("queued_by".to_owned(), queued_by.to_owned()), - ("queued_at".to_owned(), at.clone()), - ("targets".to_owned(), encoded_resolved_targets(&targets)?), - ("queue_root_ticket".to_owned(), requested_ticket.clone()), - ]); - if let Some(assignment_id) = self - .event_attributes - .get("queue_orchestrator_assignments") - .and_then(|json| serde_json::from_str::>(json).ok()) - .and_then(|assignments| assignments.get(&ticket_id).cloned()) - { - attributes.insert("orchestrator_assignment_id".to_owned(), assignment_id); - } - self.insert_event(conn, &ticket_id, &TicketEvent { - kind: TicketEventKind::StateChanged, - author: Some(queued_by.to_string()), - at: Some(at.clone()), - status: None, - from: Some("ready".to_string()), - to: Some("queued".to_string()), - reason: Some("queued".to_string()), - state_field: Some("state".to_string()), - heading: Some(TicketEventKind::StateChanged.heading()), - body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), - references: Vec::new(), - attributes, - })?; - } - + 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", + params![self.workspace_id, requested_ticket, queued_by, at], + ).map_err(sqlite_err)?; + self.insert_event(conn, &requested_ticket, &TicketEvent { + kind: TicketEventKind::StateChanged, + author: Some(queued_by.to_owned()), + at: Some(at), + status: Some("open".to_owned()), + from: Some(previous.meta.workflow_state.to_string()), + to: Some("queued".to_owned()), + reason: Some("queued".to_owned()), + state_field: Some("state".to_owned()), + heading: Some(TicketEventKind::StateChanged.heading()), + body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), + references: Vec::new(), + attributes, + })?; Ok(TicketQueueOutcome { + queued_tickets: vec![requested_ticket.clone()], requested_ticket, - queued_tickets, }) }) } 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 at = now_utc(); - conn.execute("UPDATE typed_tickets SET status = 'closed', workflow_state = 'closed', workflow_state_explicit = 1, updated_at = ?3, resolution = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, at, resolution.as_str()]).map_err(sqlite_err)?; - self.insert_event(conn, &ticket_id, &TicketEvent { kind: TicketEventKind::Close, author: Some(default_author()), at: Some(at), status: Some("closed".to_string()), from: None, to: Some("closed".to_string()), reason: None, state_field: Some("state".to_string()), heading: Some(TicketEventKind::Close.heading()), body: resolution, references: Vec::new(), attributes: BTreeMap::new() }) + let previous = self.load_ticket(conn, &ticket_id)?; + let request = TicketStateUpdate { + expected_item_revision: ticket_item_revision(&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(|_| ()) }) } @@ -4046,151 +4008,6 @@ fn transitive_dependency_blockers( Ok(blockers.into_values().collect()) } -fn dependency_queue_plan( - requested_ticket: &str, - states: &HashMap, - relations: &[TicketRelation], -) -> Result> { - let requested_state = states - .get(requested_ticket) - .copied() - .ok_or_else(|| TicketError::NotFound(requested_ticket.to_owned()))?; - if requested_state != TicketWorkflowState::Ready { - return Err(TicketError::StaleWorkflowState { - expected: TicketWorkflowState::Ready.as_str().to_owned(), - actual: requested_state.as_str().to_owned(), - }); - } - - let mut prerequisites = BTreeMap::>::new(); - for relation in relations { - match relation.kind { - TicketRelationKind::DependsOn => { - prerequisites - .entry(relation.ticket_id.clone()) - .or_default() - .insert(relation.target.clone()); - } - TicketRelationKind::Blocks => { - prerequisites - .entry(relation.target.clone()) - .or_default() - .insert(relation.ticket_id.clone()); - } - TicketRelationKind::Related - | TicketRelationKind::Supersedes - | TicketRelationKind::DuplicateOf => {} - } - } - - fn visit( - ticket: &str, - states: &HashMap, - prerequisites: &BTreeMap>, - marks: &mut BTreeMap, - stack: &mut Vec, - ordered: &mut Vec, - ) -> Result<()> { - let state = states - .get(ticket) - .copied() - .ok_or_else(|| TicketError::NotFound(ticket.to_owned()))?; - if ticket_state_resolved(state) { - return Ok(()); - } - match marks.get(ticket).copied() { - Some(2) => return Ok(()), - Some(1) => { - let start = stack.iter().position(|item| item == ticket).unwrap_or(0); - let mut cycle = stack[start..].to_vec(); - cycle.push(ticket.to_owned()); - return Err(TicketError::Conflict(format!( - "ticket dependency cycle detected: {}", - cycle.join(" -> ") - ))); - } - _ => {} - } - - marks.insert(ticket.to_owned(), 1); - stack.push(ticket.to_owned()); - if let Some(dependencies) = prerequisites.get(ticket) { - for dependency in dependencies { - visit(dependency, states, prerequisites, marks, stack, ordered)?; - } - } - stack.pop(); - marks.insert(ticket.to_owned(), 2); - ordered.push(ticket.to_owned()); - Ok(()) - } - - let mut ordered = Vec::new(); - visit( - requested_ticket, - states, - &prerequisites, - &mut BTreeMap::new(), - &mut Vec::new(), - &mut ordered, - )?; - - fn collect_ready( - ticket: &str, - requested_ticket: &str, - states: &HashMap, - prerequisites: &BTreeMap>, - collected: &mut BTreeSet, - ready: &mut Vec, - ) -> Result<()> { - if !collected.insert(ticket.to_owned()) { - return Ok(()); - } - let state = states - .get(ticket) - .copied() - .ok_or_else(|| TicketError::NotFound(ticket.to_owned()))?; - if ticket != requested_ticket && state == TicketWorkflowState::Planning { - return Err(TicketError::BlockingRelations(format!( - "dependency {ticket} is still planning" - ))); - } - if matches!( - state, - TicketWorkflowState::Done | TicketWorkflowState::Closed - ) { - return Ok(()); - } - if let Some(dependencies) = prerequisites.get(ticket) { - for dependency in dependencies { - collect_ready( - dependency, - requested_ticket, - states, - prerequisites, - collected, - ready, - )?; - } - } - if state == TicketWorkflowState::Ready { - ready.push(ticket.to_owned()); - } - Ok(()) - } - - let mut ready = Vec::new(); - collect_ready( - requested_ticket, - requested_ticket, - states, - &prerequisites, - &mut BTreeSet::new(), - &mut ready, - )?; - Ok(ready) -} - fn relation_view_from_records( meta: &TicketMeta, records: &[TicketRelation], @@ -4273,13 +4090,6 @@ fn relation_view_from_records( view } -fn relation_blocker_allows_queue(blocker: &TicketRelationBlocker) -> bool { - matches!( - blocker.blocking_state, - TicketWorkflowState::Queued | TicketWorkflowState::InProgress - ) -} - fn format_relation_blockers(blockers: &[TicketRelationBlocker]) -> String { blockers .iter() @@ -4492,6 +4302,10 @@ fn validate_state_change(change: &TicketStateChange) -> Result<()> { 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)?; + } if change.body.as_str().len() > MAX_INTAKE_SUMMARY_BODY_BYTES { return Err(TicketError::Conflict(format!( "state_changed body exceeds {MAX_INTAKE_SUMMARY_BODY_BYTES} bytes" @@ -4569,7 +4383,7 @@ mod tests { } Ok(ResolvedTicketTarget { repository_key: repository_key.to_owned(), - ref_selector: ref_selector.unwrap_or("develop").to_owned(), + ref_selector: Some(ref_selector.unwrap_or("develop").to_owned()), access: TicketTargetAccess::ReadOnly, }) } @@ -4837,24 +4651,6 @@ mod tests { } } - #[test] - fn dependency_queue_plan_orders_transitive_ready_dependencies() { - let states = HashMap::from([ - ("root".to_owned(), TicketWorkflowState::Ready), - ("middle".to_owned(), TicketWorkflowState::Ready), - ("leaf".to_owned(), TicketWorkflowState::Ready), - ]); - let relations = [ - dependency_relation("root", "middle"), - dependency_relation("middle", "leaf"), - ]; - - assert_eq!( - dependency_queue_plan("root", &states, &relations).unwrap(), - vec!["leaf", "middle", "root"] - ); - } - #[test] fn transitive_blockers_project_internal_dependency_cycle_as_blocking() { let states = HashMap::from([ @@ -4876,68 +4672,17 @@ mod tests { let mut summary = summary; summary.id = "root".to_string(); assert!( - !project_ticket_workspace_item(&summary, &blockers, None) + project_ticket_workspace_item(&summary, &blockers, None) .queue_guard .can_queue_for_orchestrator ); - assert!(matches!( - dependency_queue_plan("root", &states, &relations), - Err(TicketError::Conflict(_)) - )); - } - - #[test] - fn dependency_queue_plan_stops_at_resolved_dependency() { - let states = HashMap::from([ - ("root".to_owned(), TicketWorkflowState::Ready), - ("done".to_owned(), TicketWorkflowState::Done), - ("planning".to_owned(), TicketWorkflowState::Planning), - ]); - let relations = [ - dependency_relation("root", "done"), - dependency_relation("done", "planning"), - dependency_relation("done", "root"), - ]; - - assert_eq!( - dependency_queue_plan("root", &states, &relations).unwrap(), - vec!["root"] + assert!( + ticket_queue_guard(&summary, &blockers, None) + .blocked_reason + .is_some() ); } - #[test] - fn dependency_queue_plan_rejects_transitive_planning_dependency() { - let states = HashMap::from([ - ("root".to_owned(), TicketWorkflowState::Ready), - ("middle".to_owned(), TicketWorkflowState::Queued), - ("leaf".to_owned(), TicketWorkflowState::Planning), - ]); - let relations = [ - dependency_relation("root", "middle"), - dependency_relation("middle", "leaf"), - ]; - - let error = dependency_queue_plan("root", &states, &relations).unwrap_err(); - assert!(matches!(error, TicketError::BlockingRelations(_))); - assert!(error.to_string().contains("leaf")); - } - - #[test] - fn dependency_queue_plan_reports_cycle_path() { - let states = HashMap::from([ - ("root".to_owned(), TicketWorkflowState::Ready), - ("middle".to_owned(), TicketWorkflowState::Ready), - ]); - let relations = [ - dependency_relation("root", "middle"), - dependency_relation("middle", "root"), - ]; - - let error = dependency_queue_plan("root", &states, &relations).unwrap_err(); - assert!(matches!(error, TicketError::Conflict(_))); - assert!(error.to_string().contains("root -> middle -> root")); - } - #[test] fn workspace_projection_queues_ready_ticket_for_orchestrator() { let summary = summary_with_state(TicketWorkflowState::Ready); @@ -4957,7 +4702,7 @@ mod tests { } #[test] - fn workspace_projection_blocks_ready_ticket_with_planning_dependency() { + fn workspace_projection_queues_ready_ticket_with_planning_dependency() { let summary = summary_with_state(TicketWorkflowState::Ready); let blockers = [blocker_with_state(TicketWorkflowState::Planning)]; let projection = project_ticket_workspace_item(&summary, &blockers, None); @@ -4965,11 +4710,11 @@ mod tests { assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); assert_eq!( projection.next_action, - Some(TicketWorkspaceNextAction::WaitForOrchestrator) + Some(TicketWorkspaceNextAction::QueueForOrchestrator) ); - assert!(!projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.queue_guard.can_queue_for_orchestrator); assert!(projection.blocked_reason.is_some()); - assert!(projection.disabled_reason.is_some()); + assert!(projection.disabled_reason.is_none()); } #[test] @@ -4989,12 +4734,12 @@ mod tests { .key_hint .as_deref() .unwrap_or_default() - .contains("orchestration context") + .contains("Queue only") ); } #[test] - fn workspace_projection_overlay_suppresses_duplicate_queue() { + fn workspace_projection_overlay_retains_context_without_queue_gate() { let summary = summary_with_state(TicketWorkflowState::Ready); let overlay = TicketWorkspaceStateOverlay { source: "orchestration".to_string(), @@ -5008,25 +4753,25 @@ mod tests { Some(TicketWorkspaceNextAction::WaitForOrchestrator) ); assert_eq!(projection.visible_state, "ready→prog"); - assert!(!projection.queue_guard.can_queue_for_orchestrator); + assert!(projection.queue_guard.can_queue_for_orchestrator); assert!(projection.visible_overlay.is_some()); } #[test] - fn generic_state_change_rejects_manual_start_bypass() { + fn generic_state_change_allows_direct_start_without_phase_path() { assert!( validate_generic_state_change( TicketWorkflowState::Ready, TicketWorkflowState::InProgress ) - .is_err() + .is_ok() ); assert!( validate_generic_state_change( TicketWorkflowState::Planning, TicketWorkflowState::InProgress ) - .is_err() + .is_ok() ); } @@ -5070,7 +4815,7 @@ mod tests { TicketWorkflowState::Queued, TicketWorkflowState::Planning )); - assert!(!TicketWorkflowState::is_role_transition( + assert!(TicketWorkflowState::is_role_transition( TicketWorkflowState::Planning, TicketWorkflowState::Queued )); @@ -5128,7 +4873,7 @@ mod tests { } #[test] - fn sqlite_dependency_check_blocks_transitive_planning_dependency() { + fn sqlite_dependency_check_reports_transitive_planning_dependency() { let temp = TempDir::new().unwrap(); let backend = SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace-test") .unwrap() @@ -5162,8 +4907,8 @@ mod tests { let check = backend .dependency_check(TicketIdOrSlug::Id(root.id)) .unwrap(); - assert!(!check.queue_guard.can_queue_for_orchestrator); - assert!(check.queue_tickets.is_empty()); + 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 @@ -5186,42 +4931,28 @@ mod tests { && blocker.blocking_state == TicketWorkflowState::Planning })); assert!( - !project_ticket_workspace_item(&item.summary, &item.relation_blockers, None) + project_ticket_workspace_item(&item.summary, &item.relation_blockers, None) .queue_guard .can_queue_for_orchestrator ); } #[test] - fn dependency_checks_fail_closed_without_target_authority() { - let sqlite_temp = TempDir::new().unwrap(); - let sqlite = - SqliteTicketBackend::open(sqlite_temp.path().join("tickets.db"), "workspace-test") - .unwrap(); - let mut sqlite_input = NewTicket::new("SQLite ready Ticket"); - sqlite_input.workflow_state = Some(TicketWorkflowState::Ready); - sqlite_input.targets = vec![ticket_target( - "main", - Some("develop"), - TicketTargetAccess::ReadWrite, - )]; - let sqlite_ticket = sqlite.create(sqlite_input).unwrap(); - let sqlite_check = sqlite - .dependency_check(TicketIdOrSlug::Id(sqlite_ticket.id.clone())) - .unwrap(); - assert!(!sqlite_check.queue_guard.can_queue_for_orchestrator); - assert!( - sqlite_check - .queue_guard - .blocked_reason - .as_deref() - .unwrap_or_default() - .contains("target authority is unavailable") + fn queue_does_not_require_target_authority() { + let tmp = TempDir::new().unwrap(); + let backend = + SqliteTicketBackend::open(tmp.path().join("tickets.db"), "workspace-test").unwrap(); + let ticket = backend.create(NewTicket::new("No Git or targets")).unwrap(); + let check = backend.dependency_check(ticket.id.clone().into()).unwrap(); + assert!(check.queue_guard.can_queue_for_orchestrator); + assert_eq!(check.queue_tickets, vec![ticket.id.clone()]); + assert_eq!( + backend + .queue_ready(ticket.id.clone().into(), "actor") + .unwrap() + .queued_tickets, + vec![ticket.id] ); - assert!(matches!( - sqlite.queue_ready(TicketIdOrSlug::Id(sqlite_ticket.id), "test"), - Err(TicketError::TargetAuthorityUnavailable) - )); } #[test] @@ -5278,7 +5009,7 @@ mod tests { } #[test] - fn sqlite_queue_cycle_diagnostic_leaves_all_tickets_ready() { + fn sqlite_queue_cycle_diagnostic_queues_only_explicit_ticket() { let temp = TempDir::new().unwrap(); let backend = SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace-test") .unwrap() @@ -5316,7 +5047,7 @@ mod tests { let check = backend .dependency_check(TicketIdOrSlug::Id(first.id.clone())) .unwrap(); - assert!(!check.queue_guard.can_queue_for_orchestrator); + assert!(check.queue_guard.can_queue_for_orchestrator); assert!( check .queue_guard @@ -5338,16 +5069,19 @@ mod tests { .find(|item| item.summary.id == check.ticket.id) .unwrap(); assert!( - !project_ticket_workspace_item(&item.summary, &item.relation_blockers, None) + project_ticket_workspace_item(&item.summary, &item.relation_blockers, None) .queue_guard .can_queue_for_orchestrator ); - let error = backend - .queue_ready(TicketIdOrSlug::Id(first.id.clone()), "orchestrator") - .unwrap_err(); - assert!(matches!(error, TicketError::Conflict(_))); - assert!(error.to_string().contains(" -> ")); - for ticket_id in [first.id, second.id, third.id] { + let outcome = backend + .queue_ready(first.id.clone().into(), "orchestrator") + .unwrap(); + assert_eq!(outcome.queued_tickets, vec![first.id.clone()]); + assert_eq!( + backend.show(first.id.into()).unwrap().meta.workflow_state, + TicketWorkflowState::Queued + ); + for ticket_id in [second.id, third.id] { assert_eq!( backend .show(TicketIdOrSlug::Id(ticket_id)) @@ -5360,7 +5094,7 @@ mod tests { } #[test] - fn sqlite_queue_target_failure_rolls_back_entire_ready_closure() { + fn sqlite_queue_ignores_dependency_targets() { let temp = TempDir::new().unwrap(); let backend = SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace-test") .unwrap() @@ -5388,20 +5122,16 @@ mod tests { let check = backend .dependency_check(TicketIdOrSlug::Id(root.id.clone())) .unwrap(); - assert!(!check.queue_guard.can_queue_for_orchestrator); - assert!( - check - .queue_guard - .blocked_reason - .as_deref() - .unwrap_or_default() - .contains("unknown") + assert!(check.queue_guard.can_queue_for_orchestrator); + let outcome = backend + .queue_ready(root.id.clone().into(), "orchestrator") + .unwrap(); + assert_eq!(outcome.queued_tickets, vec![root.id.clone()]); + assert_eq!( + backend.show(root.id.into()).unwrap().meta.workflow_state, + TicketWorkflowState::Queued ); - let error = backend - .queue_ready(TicketIdOrSlug::Id(root.id.clone()), "orchestrator") - .unwrap_err(); - assert!(matches!(error, TicketError::UnknownTargetRepository(_))); - for ticket_id in [dependency.id, root.id] { + for ticket_id in [dependency.id] { assert_eq!( backend .show(TicketIdOrSlug::Id(ticket_id)) @@ -5414,7 +5144,7 @@ mod tests { } #[test] - fn sqlite_queue_atomically_queues_ready_dependency_closure() { + fn sqlite_queue_queues_only_explicit_ticket_not_ready_dependency() { let tmp = TempDir::new().unwrap(); let backend = SqliteTicketBackend::open(tmp.path().join("workspace.db"), "workspace-test") .unwrap() @@ -5475,10 +5205,7 @@ mod tests { ) .unwrap(); assert_eq!(outcome.requested_ticket, implementation.id); - assert_eq!( - outcome.queued_tickets, - vec![dependency.id.clone(), implementation.id.clone()] - ); + assert_eq!(outcome.queued_tickets, vec![implementation.id.clone()]); let queued = backend .show(TicketIdOrSlug::Id(implementation.id.clone())) .unwrap(); @@ -5488,7 +5215,7 @@ mod tests { assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); assert_eq!( queued_dependency.meta.workflow_state, - TicketWorkflowState::Queued + TicketWorkflowState::Ready ); assert_eq!(queued.meta.queued_by.as_deref(), Some("orchestrator")); assert_eq!(queued.relations.blockers.len(), 1); @@ -5871,6 +5598,1102 @@ 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 item_revision_uses_canonical_max_item_edit_index_not_edit_count_or_content_hash() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let (reference, ticket) = backend + .create_with_snapshot(NewTicket::new("Revision format")) + .unwrap(); + assert_eq!(ticket_item_revision(&ticket), format!("{}:0", reference.id)); + backend + .add_event( + reference.id.clone().into(), + NewTicketEvent::new(TicketEventKind::Comment, "before edit"), + ) + .unwrap(); + let first = backend + .edit_item( + reference.id.clone().into(), + TicketItemEdit { + title: Some("Edited".to_owned()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(ticket_item_revision(&first), format!("{}:2", reference.id)); + backend + .add_event( + reference.id.clone().into(), + NewTicketEvent::new(TicketEventKind::Comment, "between edits"), + ) + .unwrap(); + let second = backend + .edit_item( + reference.id.clone().into(), + TicketItemEdit { + body: Some(MarkdownText::new("Changed body")), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(ticket_item_revision(&second), format!("{}:4", reference.id)); + let mut reordered = second; + reordered.events.reverse(); + assert_eq!( + ticket_item_revision(&reordered), + format!("{}:4", reference.id) + ); + } + + #[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("coder")) + .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("coder"); + 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("coder")); + 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("coder")); + 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("coder"); + 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("coder")); + 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("coder")); + 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("coder")); + 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("coder"); + 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_item_revision"), + Some(&ticket_item_revision(&ticket)) + ); + assert_eq!( + event + .attributes + .get("fingerprint_version") + .map(String::as_str), + Some("state-update-v1") + ); + let request = TicketStateUpdate { + expected_item_revision: ticket_item_revision(&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_item_revision"), + Some(&ticket_item_revision(&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("coder")); + 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_item_revision: ticket_item_revision(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 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_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::StaleItemRevision { .. }) + )); + 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_item_revision: "other-revision".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 item_revision_tracks_target_edits_and_aba_but_not_comments_or_state() { + let tmp = TempDir::new().unwrap(); + let backend = backend(&tmp); + let (reference, ticket) = backend + .create_with_snapshot(NewTicket::new("Revision")) + .unwrap(); + let revision = ticket_item_revision(&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_item_revision(&commented), revision); + let completed = backend + .complete(&reference.id, completion_request(&ticket, "complete")) + .unwrap(); + assert_eq!(ticket_item_revision(&completed), revision); + 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_ne!(ticket_item_revision(&restored), revision); + } + + #[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_item_revision: "".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 { + reason: "x".repeat(MAX_STATE_CHANGE_REASON_BYTES + 1), + ..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(); + let backend = backend(&tmp); + let (reference, ticket) = backend + .create_with_snapshot(NewTicket::new("Queue fence")) + .unwrap(); + let other = backend.create(NewTicket::new("Other")).unwrap(); + let assignments = BTreeMap::from([ + (reference.id.clone(), "assignment".to_owned()), + (other.id, "other-assignment".to_owned()), + ]); + let fenced = backend.clone().with_event_attributes(BTreeMap::from([( + "queue_orchestrator_assignments".to_owned(), + serde_json::to_string(&assignments).unwrap(), + )])); + assert!( + fenced + .queue_ready(reference.id.clone().into(), "actor") + .is_err() + ); + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); + let assignments = BTreeMap::from([(reference.id.clone(), "assignment".to_owned())]); + let fenced = backend.with_event_attributes(BTreeMap::from([( + "queue_orchestrator_assignments".to_owned(), + serde_json::to_string(&assignments).unwrap(), + )])); + assert_eq!( + fenced + .queue_ready(reference.id.clone().into(), "actor") + .unwrap() + .queued_tickets, + vec![reference.id.clone()] + ); + let current = fenced.show(reference.id.into()).unwrap(); + assert_eq!( + current + .events + .last() + .unwrap() + .attributes + .get("orchestrator_assignment_id") + .map(String::as_str), + Some("assignment") + ); + } + + #[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(); @@ -5913,36 +6736,36 @@ mod tests { assert_eq!(event.from.as_deref(), Some("ready")); assert_eq!(event.to.as_deref(), Some("queued")); assert_eq!(event.reason.as_deref(), Some("queued")); - let event_targets = serde_json::from_str::>( - event.attributes.get("targets").expect("canonical targets"), - ) - .unwrap(); - assert_eq!(event_targets.len(), 1); - assert_eq!(event_targets[0].repository_key, "main"); - assert_eq!(event_targets[0].access, TicketTargetAccess::ReadWrite); + 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 workflow_queue_rejects_non_ready_ticket_without_mutation() { + fn queue_accepts_all_states_without_targets() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); - let ticket = backend.create(NewTicket::new("Planning Ticket")).unwrap(); - - assert!(matches!( - backend.queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "workspace-panel"), - Err(TicketError::StaleWorkflowState { .. }) - )); - let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning); - assert!(record.meta.queued_by.is_none()); - assert!( - !record - .events - .iter() - .any(|event| event.kind == TicketEventKind::StateChanged) - ); + 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] @@ -6027,7 +6850,7 @@ mod tests { } #[test] - fn target_validation_rejects_duplicates_and_invalid_write_layouts_without_state_change() { + fn target_validation_rejects_duplicates_but_allows_read_only_targets() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); @@ -6044,7 +6867,7 @@ mod tests { let mut no_write = NewTicket::new("No write target"); no_write.targets = vec![ticket_target("docs", None, TicketTargetAccess::ReadOnly)]; let ticket = backend.create(no_write).unwrap(); - let error = backend + let ready = backend .mark_ready( TicketIdOrSlug::Id(ticket.id.clone()), TicketMarkReady { @@ -6054,10 +6877,10 @@ mod tests { intake_summary: None, }, ) - .unwrap_err(); - assert!(matches!(error, TicketError::InvalidReadWriteTargetCount(0))); + .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::Planning); + assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Ready); let mut multiple_writes = NewTicket::new("Multiple write targets"); multiple_writes.targets = vec![write_target("main"), write_target("docs")]; @@ -6086,38 +6909,46 @@ mod tests { } #[test] - fn targets_are_locked_after_planning_and_queue_revalidates_every_target() { + fn targets_can_be_edited_and_cleared_in_every_state() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); - let mut ready_input = NewTicket::new("Ready with unknown reference target"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); - ready_input.targets = vec![ - ticket_target("main", Some("develop"), TicketTargetAccess::ReadWrite), - ticket_target("unknown", Some("main"), TicketTargetAccess::ReadOnly), - ]; - let ticket = backend.create(ready_input).unwrap(); - - let edit_error = backend - .edit_item( - TicketIdOrSlug::Id(ticket.id.clone()), - TicketItemEdit { - targets: Some(TicketTargetsEdit::Clear), - ..Default::default() - }, - ) - .unwrap_err(); - assert!(edit_error.to_string().contains("locked after planning")); - - let queue_error = backend - .queue_ready(TicketIdOrSlug::Id(ticket.id.clone()), "orchestrator") - .unwrap_err(); - assert!(matches!( - queue_error, - TicketError::UnknownTargetRepository(repository) if repository == "unknown" - )); - let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); - assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Ready); - assert!(unchanged.meta.queued_at.is_none()); + for state in [ + TicketWorkflowState::Planning, + TicketWorkflowState::Ready, + TicketWorkflowState::Queued, + TicketWorkflowState::InProgress, + TicketWorkflowState::Done, + TicketWorkflowState::Closed, + ] { + let mut input = NewTicket::new(format!("Targets {state}")); + input.workflow_state = Some(state); + let ticket = backend.create(input).unwrap(); + let set = backend + .edit_item( + ticket.id.clone().into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Set { + targets: vec![write_target("main")], + }), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(set.meta.workflow_state, state); + assert_eq!(set.meta.targets, vec![write_target("main")]); + let cleared = backend + .edit_item( + ticket.id.into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Clear), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(cleared.meta.workflow_state, state); + assert!(cleared.meta.targets.is_empty()); + assert_eq!(cleared.meta.resource_key, set.meta.resource_key); + } } #[test] @@ -6136,12 +6967,15 @@ mod tests { let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); assert_eq!(record.meta.status, ExtensibleTicketStatus::Closed); assert_eq!(record.meta.workflow_state, TicketWorkflowState::Closed); - assert!( - record - .events - .iter() - .any(|event| event.kind == TicketEventKind::Close) + 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_item_revision"), + Some(&format!("{}:0", record.meta.id)) ); + assert!(close.attributes.contains_key("operation_key")); } #[test] @@ -6256,7 +7090,7 @@ mod tests { } #[test] - fn queue_rejects_planning_dependency_and_incoming_blocker_without_mutation() { + fn queue_retains_planning_dependency_and_incoming_blocker_diagnostics() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let mut blocked_input = NewTicket::new("Blocked Ready"); @@ -6274,14 +7108,14 @@ mod tests { }, ) .unwrap(); - let error = backend + let outcome = backend .queue_ready(TicketIdOrSlug::Id(blocked.id.clone()), "test") - .unwrap_err(); - assert!(matches!(error, TicketError::BlockingRelations(_))); + .unwrap(); + assert_eq!(outcome.queued_tickets.len(), 1); let unchanged = backend .show(TicketIdOrSlug::Id(blocked.id.clone())) .unwrap(); - assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Queued); assert_eq!(unchanged.relations.blockers.len(), 1); assert_eq!( unchanged.relations.blockers[0].blocking_ticket, @@ -6303,16 +7137,16 @@ mod tests { }, ) .unwrap(); - let error = backend + let outcome = backend .queue_ready(TicketIdOrSlug::Id(incoming.id.clone()), "test") - .unwrap_err(); - assert!(matches!(error, TicketError::BlockingRelations(_))); + .unwrap(); + assert_eq!(outcome.queued_tickets.len(), 1); let unchanged_incoming = backend .show(TicketIdOrSlug::Id(incoming.id.clone())) .unwrap(); assert_eq!( unchanged_incoming.meta.workflow_state, - TicketWorkflowState::Ready + TicketWorkflowState::Queued ); assert_eq!(unchanged_incoming.relations.blockers.len(), 1); assert_eq!( diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index c33f4817c..7b5a2f310 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -134,18 +134,15 @@ const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` 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. \ -The backend atomically validates and normalizes every persisted repository target, requires at least one \ -read_write target, records one typed state_changed event, and transitions planning -> ready. `reason` is optional."; +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. \ -The backend applies the same targets validation and lock as TicketMarkReady and commits the summary, \ +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."; -const QUEUE_DESCRIPTION: &str = "Queue a ready Ticket for Orchestrator routing through the typed \ -Ticket backend. The backend rejects transitive planning dependencies and cycles, atomically queues the \ -requested Ticket plus every transitive ready dependency, and leaves queued or in-progress dependencies unchanged."; -const WORKFLOW_STATE_DESCRIPTION: &str = "Transition Ticket `state` through the typed \ -Ticket backend with a bounded `state_changed` event. Treat `queued -> inprogress` \ -as the implementation acceptance step: implementation side effects should happen only after that \ -transition is accepted and recorded. Orchestrator may return `ready` or `queued` Tickets to `planning` only with a concrete missing decision/information reason."; +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."; const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \ @@ -321,6 +318,18 @@ impl TicketBackend for TicketToolBackend { 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 mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult { self.backend.mark_ready(id, request) } @@ -1953,7 +1962,7 @@ mod tests { ) -> crate::Result { Ok(crate::ResolvedTicketTarget { repository_key: repository_key.to_owned(), - ref_selector: ref_selector.unwrap_or("develop").to_owned(), + ref_selector: Some(ref_selector.unwrap_or("develop").to_owned()), access: crate::TicketTargetAccess::ReadOnly, }) } @@ -2061,15 +2070,21 @@ mod tests { } #[test] - fn state_tool_description_explains_queued_acceptance() { + 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("queued -> inprogress")); - assert!(meta.description.contains("implementation side effects")); + assert!( + meta.description + .contains("do not impose an implementation phase path") + ); + assert!( + meta.description + .contains("does not revive released work identities") + ); } #[test] @@ -2668,10 +2683,10 @@ mod tests { ) .await .unwrap(); - assert!(queued.summary.contains("2 ticket(s)")); + 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(&dependency_key)); assert!(!queued_content.contains(&target.id)); assert!(!queued_content.contains(&dependency.id)); @@ -2931,79 +2946,24 @@ mod tests { } #[tokio::test] - async fn ticket_workflow_tool_rejects_disallowed_transition_graph_edges() { + 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"); - - let mut ready_input = NewTicket::new("Ready Bypass"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); - let ready = backend.create(ready_input).unwrap(); - let ready_error = workflow - .execute( - &json!({ - "ticket": ready.id, - "from": "ready", - "to": "inprogress", - "reason": "bypass_queue", - "body": "Should not bypass Queue.\n" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap_err(); - assert!( - ready_error - .to_string() - .contains("invalid ticket workflow transition") - ); - - let mut done_input = NewTicket::new("Backward Bypass"); - done_input.workflow_state = Some(TicketWorkflowState::Done); - let done = backend.create(done_input).unwrap(); - let backward_error = workflow - .execute( - &json!({ - "ticket": done.id, - "from": "done", - "to": "planning", - "reason": "backwards", - "body": "Should not move backwards.\n" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap_err(); - assert!( - backward_error - .to_string() - .contains("invalid ticket workflow transition") - ); - - let mut queued_input = NewTicket::new("Skip Bypass"); - queued_input.workflow_state = Some(TicketWorkflowState::Queued); - let queued = backend.create(queued_input).unwrap(); - let skip_error = workflow - .execute( - &json!({ - "ticket": queued.id, - "from": "queued", - "to": "done", - "reason": "skip_inprogress", - "body": "Should not skip inprogress.\n" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap_err(); - assert!( - skip_error - .to_string() - .contains("invalid ticket workflow transition") - ); + 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] diff --git a/crates/tui/src/backend_workspace_picker.rs b/crates/tui/src/backend_workspace_picker.rs index b4b95755a..0096714ef 100644 --- a/crates/tui/src/backend_workspace_picker.rs +++ b/crates/tui/src/backend_workspace_picker.rs @@ -199,30 +199,40 @@ fn prompt_create_request_with_io( if display_name.is_empty() { return Ok(None); } - let Some(uri) = prompt_line(input, output, "Initial repository absolute path/URI: ")? else { - return Ok(None); - }; - let uri = uri.trim().to_string(); - if uri.is_empty() { - writeln!(output, "Repository path/URI is required.")?; - return Ok(None); - } - let repository_key = loop { - let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")? - else { - return Ok(None); - }; - if repository_key.trim().is_empty() { - writeln!(output, "Repository key is required.")?; - continue; - } - break repository_key; - }; - let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")? + let Some(uri) = prompt_line( + input, + output, + "Initial repository absolute path/URI (optional, Enter to skip): ", + )? else { return Ok(None); }; - let default_ref = default_ref.trim().to_string(); + let uri = uri.trim().to_string(); + let repository = if uri.is_empty() { + None + } else { + let repository_key = loop { + let Some(repository_key) = prompt_line(input, output, "Repository key (required): ")? + else { + return Ok(None); + }; + if repository_key.trim().is_empty() { + writeln!(output, "Repository key is required.")?; + continue; + } + break repository_key; + }; + let Some(default_ref) = prompt_line(input, output, "Default ref [repository default]: ")? + else { + return Ok(None); + }; + let default_ref = default_ref.trim().to_string(); + Some(CreateBackendWorkspaceRepository { + uri, + repository_key, + default_ref: (!default_ref.is_empty()).then_some(default_ref), + }) + }; let operation_key = format!( "tui-workspace-create-{}-{}", std::process::id(), @@ -234,11 +244,7 @@ fn prompt_create_request_with_io( Ok(Some(CreateBackendWorkspaceRequest { operation_key, display_name, - repository: CreateBackendWorkspaceRepository { - uri, - repository_key, - default_ref: (!default_ref.is_empty()).then_some(default_ref), - }, + repository, })) } @@ -286,15 +292,29 @@ mod tests { ); } + #[test] + fn workspace_creation_without_initial_repository_skips_repository_prompts() { + let (request, output) = prompt_create("Workspace\n\n"); + assert!(request.unwrap().repository.is_none()); + assert!(!output.contains("Repository key (required)")); + assert!(!output.contains("Default ref")); + } + #[test] fn workspace_creation_preserves_an_explicit_non_main_repository_key() { let (request, output) = prompt_create("Platform\n/srv/platform\nplatform\ndevelop\n"); let request = request.expect("complete input should create a request"); assert_eq!(request.display_name, "Platform"); - assert_eq!(request.repository.uri, "/srv/platform"); - assert_eq!(request.repository.repository_key, "platform"); - assert_eq!(request.repository.default_ref.as_deref(), Some("develop")); + assert_eq!(request.repository.as_ref().unwrap().uri, "/srv/platform"); + assert_eq!( + request.repository.as_ref().unwrap().repository_key, + "platform" + ); + assert_eq!( + request.repository.as_ref().unwrap().default_ref.as_deref(), + Some("develop") + ); assert!(output.contains("Repository key (required): ")); assert!(!output.contains("Repository key [main]")); } @@ -304,7 +324,10 @@ mod tests { let (request, output) = prompt_create("Platform\n/srv/platform\n\n \nplatform\n\n"); let request = request.expect("a later explicit key should create a request"); - assert_eq!(request.repository.repository_key, "platform"); + assert_eq!( + request.repository.as_ref().unwrap().repository_key, + "platform" + ); assert_eq!(output.matches("Repository key is required.").count(), 2); assert_eq!(output.matches("Repository key (required): ").count(), 3); } @@ -314,7 +337,7 @@ mod tests { let (request, _) = prompt_create("Workspace\n/srv/repository\n platform \n\n"); assert_eq!( - request.unwrap().repository.repository_key, + request.unwrap().repository.unwrap().repository_key, " platform ", "the Backend must validate the creator's exact Repository key" ); @@ -325,7 +348,7 @@ mod tests { let (request, _) = prompt_create("Workspace\n/srv/repository\nmain\n\n"); assert_eq!( - request.unwrap().repository.repository_key, + request.unwrap().repository.unwrap().repository_key, "main", "main remains valid when the creator enters it explicitly" ); diff --git a/crates/worker/src/controller.rs b/crates/worker/src/controller.rs index 38e36e541..9b8242604 100644 --- a/crates/worker/src/controller.rs +++ b/crates/worker/src/controller.rs @@ -1303,10 +1303,7 @@ pub(crate) fn wire_event_bridges_on_engine( }); }); - let alerter_for_worker = alerter.clone(); - worker.on_warning(move |message| { - alerter_for_worker.alert(AlertLevel::Warn, AlertSource::Engine, message.to_owned()); - }); + wire_engine_warnings(worker, alerter); // History-append broadcasts (previously `Event::SystemMessage`) // have been removed: every persistent history item is now committed @@ -1315,6 +1312,17 @@ pub(crate) fn wire_event_bridges_on_engine( // per-item commit channel is wired at the top of this function. } +/// Forward genuine Engine warnings to both live clients and the alert snapshot. +fn wire_engine_warnings( + engine: &mut agen::Engine, + alerter: &Alerter, +) { + let alerter = alerter.clone(); + engine.on_warning(move |message| { + alerter.alert(AlertLevel::Warn, AlertSource::Engine, message.to_owned()); + }); +} + fn validate_memory_lifecycle_targets( memory: &manifest::MemoryFeatureProfileConfig, subjektiv: &manifest::SubjektivFeatureProfileConfig, @@ -3906,6 +3914,198 @@ mod tests { use tempfile::TempDir; use tokio::net::UnixListener; + mod engine_alerts { + use super::*; + use agen::llm_client::event::Event as LlmEvent; + use agen::llm_client::{ClientError, Item, Request, ToolCallCompletionSupport}; + use agen::tool::{ + Tool, ToolError, ToolExecutionContext, ToolMeta, ToolOutput, ToolOutputLimits, + }; + use agen::{Engine, EngineRunExit, History, ToolCallDispatchMode}; + use std::collections::VecDeque; + use std::pin::Pin; + use std::sync::Mutex; + + #[derive(Clone)] + struct ScriptedClient { + responses: Arc>>>, + requests: Arc>>, + } + + impl ScriptedClient { + fn new(responses: Vec>) -> Self { + Self { + responses: Arc::new(Mutex::new(responses.into())), + requests: Arc::new(Mutex::new(Vec::new())), + } + } + } + + #[async_trait::async_trait] + impl LlmClient for ScriptedClient { + fn clone_boxed(&self) -> Box { + Box::new(self.clone()) + } + + fn tool_call_completion_support(&self) -> ToolCallCompletionSupport { + ToolCallCompletionSupport::ResponseComplete + } + + async fn stream( + &self, + request: Request, + ) -> Result< + Pin> + Send>>, + ClientError, + > { + self.requests.lock().unwrap().push(request); + let events = self + .responses + .lock() + .unwrap() + .pop_front() + .expect("unexpected LLM request"); + Ok(Box::pin(futures::stream::iter(events.into_iter().map(Ok)))) + } + } + + struct LargeOutputTool; + + #[async_trait::async_trait] + impl Tool for LargeOutputTool { + async fn execute( + &self, + _input_json: &str, + _ctx: ToolExecutionContext, + ) -> Result { + Ok(ToolOutput { + summary: "Fixed large output".into(), + content: Some("useful output line\n".repeat(512)), + attachments: Vec::new(), + }) + } + } + + fn finished_response() -> Vec { + vec![ + LlmEvent::text_block_start(0), + LlmEvent::text_delta(0, "Done"), + LlmEvent::text_block_stop(0, None), + ] + } + + #[tokio::test] + async fn truncated_tool_output_preserves_model_result_without_live_or_buffered_alerts() { + let client = ScriptedClient::new(vec![ + vec![ + LlmEvent::tool_use_start(0, "large-call", "large_output"), + LlmEvent::tool_input_delta(0, "{}"), + LlmEvent::tool_use_stop(0), + ], + finished_response(), + ]); + let requests = client.requests.clone(); + let mut engine = Engine::new(client); + // Isolate output limiting from the independent early-dispatch warning. + engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::AfterResponse); + engine.set_tool_output_limits(Some(ToolOutputLimits { + default_max_bytes: 256, + per_tool: Default::default(), + })); + engine.register_tool(Arc::new(|| { + ( + ToolMeta::new("large_output") + .input_schema(serde_json::json!({"type": "object"})), + Arc::new(LargeOutputTool), + ) + })); + let (tx, _) = broadcast::channel(8); + let alerter = Alerter::new(tx); + let (_, mut live) = alerter.subscribe_with_snapshot(); + wire_engine_warnings(&mut engine, &alerter); + + let output = engine.run(&mut History::new(), "Run the tool").await; + assert!( + matches!(output.result, EngineRunExit::Finished), + "{:?}", + output.result + ); + let requests = requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + let result = requests[1].items.iter().find(|item| { + matches!(item, Item::ToolResult { call_id, .. } if call_id == "large-call") + }).expect("the next model request must include the bounded tool result"); + match result { + Item::ToolResult { + summary, + content: Some(content), + is_error, + disposition, + .. + } => { + assert_eq!(summary, "Fixed large output"); + assert!(!is_error); + assert_eq!(*disposition, agen::ToolResultDisposition::Success); + assert!(content.len() <= 256, "{content}"); + assert!(content.starts_with("useful output line\n"), "{content}"); + assert!(content.contains("[truncated:"), "{content}"); + assert!(content.contains("refine your query]"), "{content}"); + } + other => panic!("unexpected model-visible result: {other:?}"), + } + assert!(matches!( + live.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + let (snapshot, _) = alerter.subscribe_with_snapshot(); + assert!(snapshot.is_empty(), "unexpected alerts: {snapshot:?}"); + } + + #[tokio::test] + async fn unsupported_early_dispatch_warning_reaches_live_and_buffered_engine_alerts() { + let mut engine = Engine::new(ScriptedClient::new(vec![finished_response()])); + engine.set_tool_call_dispatch_mode(ToolCallDispatchMode::OnToolCallComplete); + let (tx, _) = broadcast::channel(8); + let alerter = Alerter::new(tx); + let (_, mut live) = alerter.subscribe_with_snapshot(); + wire_engine_warnings(&mut engine, &alerter); + + let output = engine.run(&mut History::new(), "Finish").await; + assert!( + matches!(output.result, EngineRunExit::Finished), + "{:?}", + output.result + ); + let alert = match live.try_recv() { + Ok(Event::Alert(alert)) => alert, + other => panic!("expected live Engine warning: {other:?}"), + }; + assert_eq!(alert.level, AlertLevel::Warn); + assert_eq!(alert.source, AlertSource::Engine); + assert!( + alert.message.contains( + "provider does not expose a trustworthy per-call completion boundary" + ), + "{}", + alert.message + ); + assert!(matches!( + live.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + let (snapshot, mut late_live) = alerter.subscribe_with_snapshot(); + assert_eq!(snapshot.len(), 1, "unexpected alerts: {snapshot:?}"); + assert_eq!(snapshot[0].level, alert.level); + assert_eq!(snapshot[0].source, alert.source); + assert_eq!(snapshot[0].message, alert.message); + assert_eq!(snapshot[0].timestamp_ms, alert.timestamp_ms); + assert!(matches!( + late_live.try_recv(), + Err(broadcast::error::TryRecvError::Empty) + )); + } + } + #[test] fn runtime_session_head_is_materialized_after_feature_installation_before_exposure() { let production = include_str!("controller.rs") diff --git a/crates/worker/src/feature/builtin/manage_worker.rs b/crates/worker/src/feature/builtin/manage_worker.rs index 789229581..b88574510 100644 --- a/crates/worker/src/feature/builtin/manage_worker.rs +++ b/crates/worker/src/feature/builtin/manage_worker.rs @@ -875,7 +875,7 @@ impl WorkerOperation { "Restore a stopped Backend/Runtime Worker session in the current Workspace. Supply expected_observation_token from WorkerList; a conflict requires refreshing and deliberate new intent. New requests default to the durable Tool call ID. For explicit recovery of an unknown/pending outcome, supply the reported request_id and the original token unchanged; never create a new intent or automatically retry." } Self::Remove => { - "Remove an eligible stopped, unassigned, non-internal Worker. Supply a bounded reason; Backend validation and retention are authoritative." + "Remove an eligible stopped, non-internal Worker with no unfinished Ticket work. Terminal Ticket responsibility is retained and does not require unassignment. Supply a bounded reason; Backend validation and retention are authoritative." } } } diff --git a/crates/worker/src/feature/builtin/merge_request.rs b/crates/worker/src/feature/builtin/merge_request.rs index 88460b2d7..28ad9418d 100644 --- a/crates/worker/src/feature/builtin/merge_request.rs +++ b/crates/worker/src/feature/builtin/merge_request.rs @@ -41,13 +41,12 @@ fn workflow_instruction() -> FeatureInstructionDeclaration { .expect("static Merge Request workflow instruction declaration is valid") } -const ALL_KINDS: [Kind; 6] = [ +const ALL_KINDS: [Kind; 5] = [ Kind::Show, Kind::Open, Kind::Review, Kind::Readiness, Kind::Complete, - Kind::CompleteTicket, ]; #[derive(Clone, Copy, Debug, Eq, PartialEq)] @@ -56,7 +55,6 @@ enum Kind { Readiness, Open, Complete, - CompleteTicket, Review, } #[derive(Clone)] @@ -98,14 +96,6 @@ struct CompleteMergeRequestInput { resolution: MergeResolutionInput, } #[derive(Debug, Deserialize, JsonSchema)] -struct CompleteTicketInput { - ticket: String, - operation_id: String, - item_revision: String, - merge_request_ids: Vec, - requirement_approval_event_id: String, -} -#[derive(Debug, Deserialize, JsonSchema)] #[serde(rename_all = "snake_case")] enum MergeStrategyInput { FastForward, @@ -150,7 +140,7 @@ impl Kind { Self::Open => config.open, Self::Review => config.review, Self::Readiness => config.readiness_check, - Self::Complete | Self::CompleteTicket => config.complete, + Self::Complete => config.complete, } } @@ -160,7 +150,6 @@ impl Kind { Self::Readiness => "CheckMergeRequestReadiness", Self::Open => "OpenMergeRequest", Self::Complete => "CompleteMergeRequest", - Self::CompleteTicket => "CompleteTicket", Self::Review => "ReviewMergeRequest", } } @@ -170,16 +159,12 @@ impl Kind { Self::Readiness => json!(schemars::schema_for!(MergeRequestInput)), Self::Open => json!(schemars::schema_for!(OpenMergeRequestInput)), Self::Complete => json!(schemars::schema_for!(CompleteMergeRequestInput)), - Self::CompleteTicket => json!(schemars::schema_for!(CompleteTicketInput)), Self::Review => json!(schemars::schema_for!(ReviewMergeRequestInput)), } } fn mutating(self) -> bool { - matches!( - self, - Self::Open | Self::Review | Self::Complete | Self::CompleteTicket - ) + matches!(self, Self::Open | Self::Review | Self::Complete) } } #[async_trait] @@ -240,23 +225,6 @@ impl Tool for MergeRequestTool { ), ) } - Kind::CompleteTicket => { - let v: CompleteTicketInput = parse(input)?; - nonempty_named("ticket", &v.ticket)?; - ( - WorkspaceRequestMethod::Post, - format!( - "/api/w/{ws}/tickets/{}/complete", - encode_path_segment(&v.ticket) - ), - Some(json!({ - "operation_id": v.operation_id, - "item_revision": v.item_revision, - "merge_request_ids": v.merge_request_ids, - "requirement_approval_event_id": v.requirement_approval_event_id, - })), - ) - } Kind::Review => { let v: ReviewMergeRequestInput = parse(input)?; let ctx = self.client.reviewer_context().ok_or_else(|| { @@ -499,11 +467,6 @@ fn native_merge_request_operation(kind: Kind) -> NativeMergeRequestOperation { surface: NativeMergeRequestSurface::Item, identity_field: Some("merge_request_id"), }, - Kind::CompleteTicket => NativeMergeRequestOperation { - operation: "complete_ticket", - surface: NativeMergeRequestSurface::Collection, - identity_field: None, - }, } } @@ -584,8 +547,8 @@ pub fn mount_workspace_http_merge_request_wip( let collection_descriptor = merge_request_descriptor( &collection_tools, - "Open Merge Requests and complete Tickets through existing scoped authority", - "Only collection-scoped Merge Request Feature operations enabled for this Worker are published. Opening retains repository and selector inputs; Ticket completion retains its full guarded result-set inputs.", + "Open Merge Requests through existing scoped authority", + "Only collection-scoped Merge Request Feature operations enabled for this Worker are published. Opening retains repository and selector inputs; Ticket completion is a separate Ticket operation.", )?; registry.mount(WipProjection { route: collection_route.clone(), @@ -617,7 +580,7 @@ pub fn mount_workspace_http_merge_request_wip( let item_descriptor = merge_request_descriptor( &item_tools, "Inspect and operate on the Merge Request bound to this object route", - "The subject Merge Request identity comes exclusively from the target route. Repository selectors, exact review capability/candidate, Ticket result snapshot, approval, target refs, strategy, and resolution remain ordinary Backend-validated preconditions.", + "The subject Merge Request identity comes exclusively from the target route. Repository selectors, exact review capability/candidate, approval, target refs, strategy, and resolution remain ordinary Backend-validated preconditions.", )?; registry.mount_dynamic(WipDynamicMount { collection_route: collection_route.clone(), @@ -665,18 +628,6 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler { .get(operation) .ok_or_else(merge_request_operation_not_found)?; let input = native_merge_request_input(arguments, None, tool.projection.identity_field)?; - let affected_merge_requests = if tool.kind == Kind::CompleteTicket { - input - .get("merge_request_ids") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .map(ToOwned::to_owned) - .collect::>() - } else { - Vec::new() - }; let output = execute_native_merge_request_tool(tool, &self.permissions, input, None, context) .await?; @@ -699,18 +650,6 @@ impl WipOperationHandler for MergeRequestCollectionWipHandler { ); } } - Kind::CompleteTicket => { - for reference in affected_merge_requests { - if is_merge_request_route_reference(&reference) { - record_merge_request_observation( - &self.revisions, - &reference, - &response, - true, - ); - } - } - } _ => {} } Ok(WipOperationOutput::native( @@ -1148,9 +1087,6 @@ pub fn description(n: &str) -> Option<&'static str> { "CompleteMergeRequest" => Some( "Record Orchestrator-owned integration for the explicitly addressed Merge Request without completing the Ticket or releasing its assignment.", ), - "CompleteTicket" => Some( - "Complete the Ticket only after reviewing its current item revision and exact linked Merge Request result set; this releases the current assignment atomically.", - ), "ReviewMergeRequest" => Some( "Submit the injected Reviewer capability result for its captured exact source ref; source movement cancels it, while target-only movement does not.", ), @@ -1220,7 +1156,6 @@ mod tests { "CheckMergeRequestReadiness", "OpenMergeRequest", "CompleteMergeRequest", - "CompleteTicket", "ReviewMergeRequest", ] { assert!(description(name).is_some(), "missing operation {name}"); @@ -1369,8 +1304,7 @@ mod tests { [ "ShowMergeRequest", "CheckMergeRequestReadiness", - "CompleteMergeRequest", - "CompleteTicket" + "CompleteMergeRequest" ] ); assert_eq!(install(coder).1, [FEATURE_PROMPT_REF]); @@ -1427,7 +1361,6 @@ mod tests { (NativeMergeRequestSurface::Item, "read"), (NativeMergeRequestSurface::Item, "check_readiness"), (NativeMergeRequestSurface::Item, "complete"), - (NativeMergeRequestSurface::Collection, "complete_ticket"), ] ); diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index 6d8b0ffd5..a3367826c 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -1,5 +1,6 @@ use serde::Serialize; use serde_json::{Map, Value}; +use server_api::TicketSourceRefObservation; #[derive(Debug, Serialize)] pub(super) struct ModelTicketQueryResponse { @@ -168,17 +169,22 @@ struct ModelMergeRequest { selector_to: String, review_status: String, subject_ref: Option, + source_ref_observation: TicketSourceRefObservation, + integration_evidence_error: Option, review_excerpt: Option, } #[derive(Debug, Serialize)] struct ModelTicketEvidence { + /// MR evidence is inapplicable without a linked MR, independent of Ticket decisions. + integration_applicable: bool, has_merge_request: bool, has_current_subject_ref: bool, has_review_request: bool, has_commit: bool, review_status: Option, approved_current_subject: bool, + review_after_rescope: bool, unresolved_request_changes: bool, complete_for_integration: bool, missing: Vec, @@ -517,23 +523,47 @@ fn project_merge_request(value: &Value) -> Result { selector_to: string_field(merge, "selector_to")?, review_status: string_field(merge, "review_status")?, subject_ref: optional_string(merge, "current_subject_ref")?, + source_ref_observation: serde_json::from_value( + merge + .get("source_ref_observation") + .ok_or_else(|| "missing source_ref_observation".to_string())? + .clone(), + ) + .map_err(|error| format!("invalid source_ref_observation: {error}"))?, + integration_evidence_error: optional_string(merge, "integration_evidence_error")?, review_excerpt: optional_string(merge, "review_excerpt")?, }) } fn project_evidence(value: &Value) -> Result { let evidence = object(value, "Ticket evidence")?; - Ok(ModelTicketEvidence { - has_merge_request: bool_field(evidence, "has_merge_request")?, + let has_merge_request = bool_field(evidence, "has_merge_request")?; + let projected = ModelTicketEvidence { + integration_applicable: has_merge_request, + has_merge_request, has_current_subject_ref: bool_field(evidence, "has_current_subject_ref")?, has_review_request: bool_field(evidence, "has_review_request")?, has_commit: bool_field(evidence, "has_commit")?, review_status: optional_string(evidence, "review_status")?, approved_current_subject: bool_field(evidence, "approved_current_subject")?, + review_after_rescope: bool_field(evidence, "review_after_rescope")?, unresolved_request_changes: bool_field(evidence, "unresolved_request_changes")?, complete_for_integration: bool_field(evidence, "complete_for_integration")?, missing: string_array(evidence, "missing")?, - }) + }; + if !has_merge_request { + // Empty MR sets are neither missing evidence nor vacuous approval. + return Ok(ModelTicketEvidence { + review_status: None, + approved_current_subject: false, + review_after_rescope: false, + unresolved_request_changes: false, + complete_for_integration: false, + missing: Vec::new(), + ..projected + }); + } + Ok(projected) } fn project_actions(value: &Value) -> Result { @@ -664,6 +694,38 @@ mod tests { use super::*; use serde_json::json; + #[test] + fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() { + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + let projected = project_ticket_detail(json!({ + "resource_key": "T-718", "item_revision": "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, + "evidence": { + "has_merge_request": false, "has_current_subject_ref": false, "has_review_request": false, "has_commit": false, + "review_status": "approved", "approved_current_subject": true, "review_after_rescope": true, + "unresolved_request_changes": false, "complete_for_integration": true, "missing": ["merge_request"] + } + })).unwrap(); + let projected = serde_json::to_value(projected).unwrap(); + assert_eq!(projected["state"], state); + assert_eq!(projected["thread"][0]["kind"], "completed"); + assert_eq!(projected["evidence"]["integration_applicable"], false); + assert_eq!(projected["evidence"]["review_status"], Value::Null); + assert_eq!(projected["evidence"]["approved_current_subject"], false); + assert_eq!(projected["evidence"]["complete_for_integration"], false); + assert_eq!(projected["evidence"]["missing"], json!([])); + } + } + #[test] fn objective_projection_exposes_only_resource_references() { let projected = project_objective_detail(json!({ @@ -735,6 +797,7 @@ mod tests { "has_commit": false, "review_status": null, "approved_current_subject": false, + "review_after_rescope": false, "unresolved_request_changes": false, "complete_for_integration": false, "missing": ["merge_request"] @@ -817,6 +880,7 @@ mod tests { "has_commit": false, "review_status": null, "approved_current_subject": false, + "review_after_rescope": false, "unresolved_request_changes": false, "complete_for_integration": false, "missing": ["merge_request"] @@ -844,6 +908,123 @@ mod tests { assert!(!projected.to_string().contains("internal-blocker")); } + #[test] + fn ticket_projection_preserves_source_diagnostics_separately_from_rescope_attestation() { + for (state, subject_ref, observation, integration_error, attested) in [ + ( + "merged", + Some("immutable-approved-source"), + json!({"status": "not_required"}), + None, + true, + ), + ( + "open", + None, + json!({"status": "unavailable", "code": "source_ref_unavailable"}), + Some("source_ref_unavailable"), + true, + ), + ( + "open", + Some("observed-source"), + json!({"status": "observed"}), + None, + false, + ), + ] { + let merged = state == "merged"; + let merge_request = json!({ + "merge_request_id": "mr-source", + "repository_key": "repository", + "state": state, + "selector_from": "source-branch", + "selector_to": "main", + "review_status": "approved", + "current_subject_ref": subject_ref, + "source_ref_observation": observation, + "integration_evidence_error": integration_error, + "review_excerpt": "Reviewed source" + }); + let projected = project_ticket_detail(json!({ + "resource_key": "T-716", + "item_revision": "current-ticket-revision", + "title": "Source evidence", + "body": "Body", + "state": if merged { "done" } else { "inprogress" }, + "events": [], + "linked_objectives": [], + "assignments": [], + "implementation_reports": [], + "merge_requests": [merge_request.clone()], + "merge_request": merge_request, + "evidence": { + "has_merge_request": true, + "has_current_subject_ref": subject_ref.is_some(), + "has_review_request": true, + "has_commit": subject_ref.is_some(), + "review_status": "approved", + "approved_current_subject": subject_ref.is_some(), + "review_after_rescope": attested, + "unresolved_request_changes": false, + "complete_for_integration": merged, + "missing": if subject_ref.is_none() { + vec!["current_subject_ref"] + } else { + vec![] + } + } + })) + .expect("source evidence must project"); + let projected = serde_json::to_value(projected).expect("serialize Ticket detail"); + + for merge in [&projected["merge_requests"][0], &projected["merge_request"]] { + assert_eq!(merge["subject_ref"], json!(subject_ref)); + assert_eq!(merge["source_ref_observation"], observation); + assert_eq!( + merge.get("integration_evidence_error"), + Some(&json!(integration_error)) + ); + } + assert_eq!(projected["item_revision"], "current-ticket-revision"); + assert_eq!(projected["evidence"]["review_after_rescope"], attested); + assert_eq!(projected["evidence"]["complete_for_integration"], merged); + } + } + + #[test] + fn merge_request_projection_rejects_missing_or_invalid_source_observation() { + let merge_request = json!({ + "merge_request_id": "mr-source", + "repository_key": "repository", + "state": "open", + "selector_to": "main", + "review_status": "none", + "current_subject_ref": null, + "integration_evidence_error": null + }); + assert!( + project_merge_request(&merge_request) + .expect_err("source observation is required") + .contains("source_ref_observation") + ); + for observation in [ + Value::Null, + json!({"status": "unknown"}), + json!({"status": "unavailable"}), + json!({"status": "unavailable", "code": 42}), + json!({"status": "observed", "code": "unexpected"}), + ] { + let mut merge_request = merge_request.clone(); + merge_request["source_ref_observation"] = observation; + assert!( + project_merge_request(&merge_request) + .expect_err("invalid source observation must fail") + .contains("source_ref_observation") + ); + } + } + #[test] fn relation_projection_accepts_current_workspace_api_shapes() { let outgoing = project_relation( diff --git a/crates/worker/src/feature/builtin/ticket.rs b/crates/worker/src/feature/builtin/ticket.rs index f740ae6d0..0a0883918 100644 --- a/crates/worker/src/feature/builtin/ticket.rs +++ b/crates/worker/src/feature/builtin/ticket.rs @@ -101,6 +101,7 @@ enum WorkspaceTicketEvidenceFilter { #[serde(rename_all = "snake_case")] enum WorkspaceTicketReviewFilter { None, + Unknown, Pending, Approved, RequestChanges, @@ -284,6 +285,227 @@ fn workspace_ticket_read_definition( }) } +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct WorkspaceCompleteTicketInput { + /// Ticket reference. Prefer `T-*`. + ticket: String, + /// Stable key for exact replay of this completion decision. + operation_key: String, + /// Current item revision from ShowTicket. + expected_item_revision: 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. + #[serde(default)] + references: Vec, +} + +#[derive(Debug, Deserialize, Serialize, JsonSchema)] +#[serde(deny_unknown_fields)] +struct WorkspaceTicketStateUpdateInput { + /// Ticket reference. Prefer `T-*`. + ticket: String, + operation_key: String, + /// Current item revision from ShowTicket. + expected_item_revision: 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, +} + +#[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, + /// Current item revision from ShowTicket. + expected_item_revision: String, + expected_state: TicketWorkflowState, + /// Required explanation for closing this Ticket. + resolution: String, + #[serde(default)] + references: Vec, +} + +const TICKET_CLOSE_DESCRIPTION: &str = "Close a Ticket with resolution, item/state CAS, and replay key. No Merge Request or enforced state sequence is required. Actor identity is transport-bound; this does not integrate Merge Requests or expand Workdir grants."; +const COMPLETE_TICKET_DESCRIPTION: &str = "Record a Ticket completion decision with reason, item/state CAS, and replay key. Merge Requests and approvals are not prerequisites. The Backend authorizes the assigned Worker or registered Workspace Orchestrator; roles supplied by the model never grant authority."; +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. This does not start Workers, queue dependencies, integrate Merge Requests, or enlarge live Workdir grants; the Backend validates actor authority."; + +#[derive(Clone, Copy)] +enum WorkspaceTicketDecisionKind { + Complete, + UpdateState, + Close, +} + +impl WorkspaceTicketDecisionKind { + fn name(self) -> &'static str { + match self { + Self::Complete => "CompleteTicket", + Self::UpdateState => "TicketWorkflowState", + Self::Close => "TicketClose", + } + } + + fn description(self) -> &'static str { + match self { + Self::Complete => COMPLETE_TICKET_DESCRIPTION, + Self::UpdateState => TICKET_STATE_UPDATE_DESCRIPTION, + Self::Close => TICKET_CLOSE_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)), + } + } +} + +#[derive(Clone)] +struct WorkspaceTicketDecisionTool { + client: Arc, + kind: WorkspaceTicketDecisionKind, +} + +fn validate_ticket_decision( + ticket: &str, + operation_key: &str, + revision: &str, + reason: &str, +) -> Result<(), ToolError> { + for (name, value) in [ + ("ticket", ticket), + ("operation_key", operation_key), + ("expected_item_revision", revision), + ("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(|| { + 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_item_revision, + &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_item_revision, + &value.resolution, + )?; + let body = json!({ + "operation_key": value.operation_key, + "expected_item_revision": value.expected_item_revision, + "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_item_revision, + &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())) + ), + Some(body), + ) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + Ok(ToolOutput { + summary: self.kind.name().into(), + content: Some( + serde_json::to_string(&ticket) + .map_err(|error| ToolError::Internal(error.to_string()))?, + ), + attachments: Vec::new(), + }) + } +} + +fn workspace_ticket_decision_definition( + client: Arc, + kind: WorkspaceTicketDecisionKind, +) -> ToolDefinition { + Arc::new(move || { + ( + ToolMeta::new(kind.name()) + .description(kind.description()) + .input_schema(kind.schema()), + Arc::new(WorkspaceTicketDecisionTool { + client: client.clone(), + kind, + }) as Arc, + ) + }) +} + +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(), + _ => ticket_tool_description(name, None), + } +} + const FEATURE_ID: &str = "ticket"; const FEATURE_NAME: &str = "Ticket tools"; const FEATURE_DESCRIPTION: &str = @@ -410,11 +632,16 @@ impl TicketFeatureAccess { .iter() .copied() .filter(|name| self.allows_tool(name)) + .chain((self.authoring || self.workflow || self.thread).then_some("CompleteTicket")) .collect() } fn allows_tool(self, name: &str) -> bool { + // Publishing a decision tool is not an authority grant. The injected + // 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)) || (self.authoring && AUTHORING_TOOL_NAMES.contains(&name)) || (self.thread && THREAD_TOOL_NAMES.contains(&name)) || (self.intake && INTAKE_TOOL_NAMES.contains(&name)) @@ -447,9 +674,11 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketComment", "TicketMarkReady", "TicketQueue", + "TicketWorkflowState", "TicketClose", "TicketRelationRecord", "TicketRelationRemove", + "CompleteTicket", ]; #[cfg(test)] @@ -464,6 +693,7 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "TicketRelationRemove", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", + "CompleteTicket", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ @@ -517,7 +747,7 @@ impl FeatureModule for TicketFeature { for name in enabled_tool_names { descriptor = descriptor.with_tool(ToolDeclaration::new( name, - ticket_tool_description(name, None), + workspace_ticket_tool_description(name), )); } descriptor @@ -743,6 +973,49 @@ 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_item_revision: ticket::ticket_item_revision(&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, @@ -835,14 +1108,15 @@ impl WorkspaceHttpTicketBackend { TicketError::Conflict(format!("serialize Ticket event: {error}")) })?), ), - TicketBackendOperation::AddStateChanged { id, change } => Self::request_unit( - client, - WorkspaceRequestMethod::Post, - format!("{base}/{}/state-changes", Self::ticket_path(&id)), - Some(serde_json::to_value(change).map_err(|error| { - TicketError::Conflict(format!("serialize Ticket state change: {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::AddIntakeSummary { id, summary } => Self::request_unit( client, WorkspaceRequestMethod::Post, @@ -851,26 +1125,57 @@ impl WorkspaceHttpTicketBackend { TicketError::Conflict(format!("serialize Ticket intake summary: {error}")) })?), ), - TicketBackendOperation::SetStateField { id, field, change } => Self::request_unit( + 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, + WorkspaceRequestMethod::Post, + format!( + "{base}/{}/state-fields/{}", + Self::ticket_path(&id), + Self::ticket_path(&TicketIdOrSlug::Query(field)) + ), + Some(body), + ) + } + TicketBackendOperation::SetWorkflowState { id, change } => { + let body = Self::legacy_state_request(client.clone(), &base, &id, change)?; + Self::request_unit( + client, + WorkspaceRequestMethod::Post, + format!("{base}/{}/workflow-state", Self::ticket_path(&id)), + Some(body), + ) + } + TicketBackendOperation::UpdateState { ticket, request } => Self::request( client, WorkspaceRequestMethod::Post, format!( - "{base}/{}/state-fields/{}", - Self::ticket_path(&id), - Self::ticket_path(&TicketIdOrSlug::Query(field)) + "{base}/{}/state-update", + Self::ticket_path(&TicketIdOrSlug::from(ticket.as_str())) ), - Some(serde_json::to_value(change).map_err(|error| { - TicketError::Conflict(format!("serialize Ticket state field change: {error}")) + Some(serde_json::to_value(request).map_err(|error| { + TicketError::Conflict(format!("serialize Ticket state update: {error}")) })?), - ), - TicketBackendOperation::SetWorkflowState { id, change } => Self::request_unit( + ) + .map(TicketBackendOperationResult::Ticket), + TicketBackendOperation::Complete { ticket, request } => Self::request( client, WorkspaceRequestMethod::Post, - format!("{base}/{}/workflow-state", Self::ticket_path(&id)), - Some(serde_json::to_value(change).map_err(|error| { - TicketError::Conflict(format!("serialize Ticket workflow change: {error}")) + 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, @@ -887,14 +1192,36 @@ impl WorkspaceHttpTicketBackend { None, ) .map(TicketBackendOperationResult::QueueOutcome), - TicketBackendOperation::Close { id, resolution } => Self::request_unit( - client, - WorkspaceRequestMethod::Post, - format!("{base}/{}/workflow/close", Self::ticket_path(&id)), - Some(serde_json::to_value(resolution).map_err(|error| { + 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_item_revision: ticket::ticket_item_revision(&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)?; @@ -1113,6 +1440,20 @@ impl TicketBackend for WorkspaceHttpTicketBackend { } } + fn update_state( + &self, + ticket: &str, + request: ticket::TicketStateUpdate, + ) -> TicketResult { + expect_ticket_result!( + self.invoke(TicketBackendOperation::UpdateState { + ticket: ticket.to_string(), + request + }), + TicketBackendOperationResult::Ticket + ) + } + fn mark_ready( &self, id: TicketIdOrSlug, @@ -1291,6 +1632,12 @@ fn native_ticket_operation(tool_name: &str) -> Option { identity_field: Some("ticket"), mutating: true, }, + "CompleteTicket" => NativeTicketOperation { + operation: "complete", + surface: NativeTicketSurface::Item, + identity_field: Some("ticket"), + mutating: true, + }, "TicketClose" => NativeTicketOperation { operation: "close", surface: NativeTicketSurface::Item, @@ -1347,7 +1694,7 @@ pub(crate) fn enabled_ticket_definitions( ) -> Vec { let backend = TicketToolBackend::new(WorkspaceHttpTicketBackend::new(client.clone())); let allowed = access.tool_names(); - ticket_tools(backend) + let mut definitions: Vec = ticket_tools(backend) .into_iter() .filter_map(|definition| { let (meta, _) = definition(); @@ -1361,10 +1708,25 @@ pub(crate) fn enabled_ticket_definitions( "ShowTicket" => { workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show) } + "TicketClose" => workspace_ticket_decision_definition( + client.clone(), + WorkspaceTicketDecisionKind::Close, + ), + "TicketWorkflowState" => workspace_ticket_decision_definition( + client.clone(), + WorkspaceTicketDecisionKind::UpdateState, + ), _ => definition, }) }) - .collect() + .collect(); + if allowed.contains(&"CompleteTicket") { + definitions.push(workspace_ticket_decision_definition( + client, + WorkspaceTicketDecisionKind::Complete, + )); + } + definitions } fn native_ticket_tools( @@ -2015,6 +2377,637 @@ mod tests { use std::net::TcpListener; use std::thread; + #[derive(Debug)] + struct DecisionClient { + requests: Mutex>, + response: crate::worker::WorkspaceResponse, + } + + impl WorkspaceClient for DecisionClient { + fn workspace_id(&self) -> Option<&str> { + Some("workspace") + } + fn kind(&self) -> &str { + "test-decisions" + } + fn is_available(&self) -> bool { + true + } + fn execute( + &self, + request: WorkspaceRequest, + ) -> Result { + let mut response = self.response.clone(); + // Legacy adapters read a snapshot before the mutation. Let tests + // inject mutation rejection independently of that successful read. + if request.method == WorkspaceRequestMethod::Get { + response.status = 200; + } + self.requests.lock().unwrap().push(request); + Ok(response) + } + } + + fn decision_client(status: u16, state: TicketWorkflowState) -> Arc { + let temp = tempfile::tempdir().unwrap(); + let db = + ticket::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap(); + let created = db.create(NewTicket::new("Decision")).unwrap(); + let mut record = db.show(TicketIdOrSlug::Id(created.id)).unwrap(); + record.meta.resource_key = Some("T-718".into()); + record.meta.workflow_state = state; + Arc::new(DecisionClient { + requests: Mutex::new(Vec::new()), + response: crate::worker::WorkspaceResponse { + status, + body: serde_json::to_string(&record).unwrap(), + }, + }) + } + + fn decision_input() -> Value { + json!({ + "ticket": "T-718", "operation_key": "decision-1", + "expected_item_revision": "revision-1", "expected_state": "planning", + "reason": "No repository changes are needed" + }) + } + + fn close_input() -> Value { + 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_item_revision, + ticket::ticket_item_revision(&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_item_revision, + ticket::ticket_item_revision(&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_item_revision", + "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 revisions = Arc::new(Mutex::new(TicketRevisionState::default())); + let resolver = TicketItemResolver { + tools: operation_map(tools), + permissions: None, + collection_route: "/tickets".into(), + revisions, + }; + 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); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 1); + assert_eq!( + requests[0].path, + "/api/w/workspace/tickets/T-718/state-update" + ); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + assert_eq!( + body, + json!({ + "operation_key": "decision-1", "expected_item_revision": "revision-1", + "expected_state": "planning", "state": "closed", + "reason": "No repository changes are needed", "references": [] + }) + ); + } + + #[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, + .. + }) + )); + 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_item_revision": "revision-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() { + for state in [ + "planning", + "ready", + "queued", + "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" + ); + 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, + ] { + 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 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_item_revision", + "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_item_revision", + "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 invalid = valid.clone(); + invalid["expected_state"] = json!("approved"); + assert!(!validator.is_valid(&invalid)); + 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() { + 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::work_report()).unwrap(); + let tool = tools + .iter() + .find(|tool| tool.name == "CompleteTicket") + .unwrap(); + let error = execute_native_ticket_tool( + tool, + &None, + decision_input(), + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "self-reported-coder".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); + } + } + + #[tokio::test] + async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() { + let client = decision_client(200, TicketWorkflowState::Done); + let revisions = Arc::new(Mutex::new(TicketRevisionState::default())); + let tools = + native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap(); + let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); + let complete = descriptor + .operations + .iter() + .find(|op| op.name == "complete") + .unwrap(); + assert!(!complete.parameters.iter().any(|p| p.name == "ticket")); + for field in [ + "operation_key", + "expected_item_revision", + "expected_state", + "reason", + ] { + assert!( + complete + .parameters + .iter() + .any(|p| p.name == field && p.required) + ); + } + let resolver = TicketItemResolver { + tools: operation_map(tools), + permissions: None, + collection_route: "/tickets".into(), + revisions: revisions.clone(), + }; + let bound = resolver.resolve("T-718").unwrap(); + let before = bound.object.validator; + let other = resolver.resolve("T-719").unwrap().object.validator; + let mut input = decision_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( + "complete", + &arguments, + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "coder".into(), + }, + ) + .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" + ); + } + #[test] fn native_ticket_projection_matches_role_inventory_and_binds_item_identity() { let client: Arc = Arc::new( @@ -2037,9 +3030,11 @@ mod tests { "comment", "mark_ready", "queue", + "transition", "close", "record_relation", "remove_relation", + "complete", ] ); let item = ticket_descriptor( @@ -2088,6 +3083,7 @@ mod tests { "remove_relation", "record_orchestration_plan", "query_orchestration_plans", + "complete", ] ); assert_eq!( @@ -2361,13 +3357,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(), 10); + assert_eq!(tool_names.len(), 12); 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(), 10); + assert_eq!(workflow_names.len(), 11); assert!( workflow_names.len() < 12, "workflow catalog must stay below the prior broad catalog" @@ -2439,7 +3435,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(&"TicketWorkflowState")); let orchestration = workspace_feature(TicketFeatureAccess::workflow()); let orchestration_descriptor = orchestration.descriptor(); @@ -2463,7 +3459,8 @@ mod tests { .map(|tool| tool.name.as_str()) .collect::>(); assert!(work_report_tools.contains(&"TicketComment")); - assert!(!work_report_tools.contains(&"TicketWorkflowState")); + assert!(work_report_tools.contains(&"CompleteTicket")); + assert!(work_report_tools.contains(&"TicketWorkflowState")); let review = workspace_feature(TicketFeatureAccess::review()); let review_descriptor = review.descriptor(); @@ -2473,6 +3470,7 @@ mod tests { .map(|tool| tool.name.as_str()) .collect::>(); assert!(!review_tools.contains(&"TicketWorkflowState")); + assert!(!review_tools.contains(&"CompleteTicket")); } #[test] @@ -2521,7 +3519,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 == "TicketWorkflowState")); assert!( !installed .iter() diff --git a/crates/worker/src/ipc/alerter.rs b/crates/worker/src/ipc/alerter.rs index 1ab5f55b1..b36ee2b86 100644 --- a/crates/worker/src/ipc/alerter.rs +++ b/crates/worker/src/ipc/alerter.rs @@ -2,7 +2,7 @@ //! //! Separate from `tracing` (which is for developer logs). Alerts //! are short human-readable messages the Worker layer wants a client to -//! see — for example "compaction failed", "tool output truncated". +//! see — for example "compaction failed", "tool-call early dispatch deferred". //! //! Each alert is broadcast on the shared `Event` channel and //! also appended to an in-memory buffer so that clients connecting diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index 1b72b8415..6ef4aa501 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -1027,7 +1027,7 @@ mod tests { } #[test] - fn builtin_orchestrator_cleanup_policy_renders_with_common_includes() { + fn builtin_orchestrator_cleanup_retains_terminal_responsibility_and_blocks_unfinished_work() { let rendered = PromptCatalog::builtins_only() .unwrap() .render_name("role.orchestrator", Value::UNDEFINED) @@ -1040,7 +1040,21 @@ mod tests { assert!(rendered.contains("call `WorkerStop`")); assert!(rendered.contains("call `WorkerRemove`")); assert!(rendered.contains("only then call `WorkdirDelete`")); - assert!(rendered.contains("`CurrentAssignment` means unassign and reread")); + assert!(rendered.contains("must have no unfinished Ticket work")); + assert!(rendered.contains("does not require unassignment before cleanup")); + assert!(rendered.contains("`UnfinishedWork` (`unfinished_work` on the wire)")); + assert!(rendered.contains("requires explicit work end or reassignment")); + assert!(rendered.contains("idle or stopped status does not end unfinished work")); + for stale_policy in [ + "no current Ticket assignment", + "unassign it through the available orchestration authority", + "`CurrentAssignment`", + ] { + assert!( + !rendered.contains(stale_policy), + "stale cleanup policy: {stale_policy}" + ); + } assert!(!rendered.contains("{% include")); } } diff --git a/crates/worker/src/wip.rs b/crates/worker/src/wip.rs index c2043edca..54e423c7b 100644 --- a/crates/worker/src/wip.rs +++ b/crates/worker/src/wip.rs @@ -6119,17 +6119,6 @@ mod tests { }) .to_string(), }, - crate::worker::WorkspaceResponse { - status: 200, - body: json!({ - "operation_id": "complete-ticket-1", - "ticket_id": "ticket-internal", - "item_revision": "ticket-rev-1", - "merge_request_ids": ["MR-1"], - "requirement_approval_event_id": "review-1" - }) - .to_string(), - }, ])); let orchestrator_config = MergeRequestFeatureConfig { readiness_check: true, @@ -6202,51 +6191,22 @@ mod tests { .inspect("yoi.merge-request/collection/v1".into(), false) .await .unwrap(); - let before_ticket_completion = orchestrator - .host - .projection(&item) - .unwrap() - .object - .validator; - orchestrator + let rejected = orchestrator .call( collection.into(), "yoi.merge-request/collection/v1".into(), "complete_ticket".into(), - json!({ - "ticket": "T-685", - "operation_id": "complete-ticket-1", - "item_revision": "ticket-rev-1", - "merge_request_ids": ["MR-1"], - "requirement_approval_event_id": "review-1" - }), - ToolExecutionContext::direct(), - ) - .await - .unwrap(); - assert_ne!( - before_ticket_completion, - orchestrator - .host - .projection(&item) - .unwrap() - .object - .validator, - "Ticket completion must stale every Merge Request in its exact result set" - ); - let stale = orchestrator - .call( - item, - "yoi.merge-request/item/v1".into(), - "check_readiness".into(), json!({}), ToolExecutionContext::direct(), ) .await - .expect_err("affected Merge Request observation must be stale"); - assert!(stale.to_string().contains("ValidatorMismatch")); + .expect_err("Ticket completion is not a Merge Request operation"); + assert!( + rejected.to_string().contains("operation is not declared"), + "{rejected}" + ); let orchestrator_requests = orchestrator_client.requests(); - assert_eq!(orchestrator_requests.len(), 3); + assert_eq!(orchestrator_requests.len(), 2); assert_eq!( orchestrator_requests[0].path, "/api/w/workspace/merge-requests/MR-1/readiness" @@ -6260,10 +6220,6 @@ mod tests { assert_eq!(completion_body["approval_event_id"], "review-1"); assert_eq!(completion_body["target_ref_before"], "target-1"); assert_eq!(completion_body["target_ref_after"], "result-1"); - assert_eq!( - orchestrator_requests[2].path, - "/api/w/workspace/tickets/T-685/complete" - ); } #[tokio::test] diff --git a/crates/workspace-server/src/assignment_work.sql b/crates/workspace-server/src/assignment_work.sql new file mode 100644 index 000000000..72de3169c --- /dev/null +++ b/crates/workspace-server/src/assignment_work.sql @@ -0,0 +1,100 @@ +-- Responsibility is durable. A release only ends the operational authority of +-- this assignment identity; reopening a Ticket must not revive that authority. +CREATE TABLE ticket_assignment_work_releases ( + workspace_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + released_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, assignment_id), + FOREIGN KEY (workspace_id, assignment_id) + REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE +); +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') + AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases AS released + WHERE released.workspace_id = current.workspace_id + AND released.assignment_id = current.assignment_id); +CREATE TRIGGER ticket_terminal_releases_work +AFTER UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state IN ('done', 'closed') +BEGIN + INSERT OR IGNORE INTO ticket_assignment_work_releases + SELECT workspace_id, assignment_id, COALESCE(NEW.updated_at, updated_at) + FROM ticket_current_worker_assignments + WHERE workspace_id = NEW.workspace_id AND ticket_id = NEW.ticket_id; +END; +CREATE TRIGGER ticket_terminal_assignment_releases_work_insert +AFTER INSERT ON ticket_current_worker_assignments +WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state IN ('done', 'closed')) +BEGIN + INSERT OR IGNORE INTO ticket_assignment_work_releases + VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at); +END; +CREATE TRIGGER ticket_terminal_assignment_releases_work_update +AFTER UPDATE ON ticket_current_worker_assignments +WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state IN ('done', 'closed')) +BEGIN + INSERT OR IGNORE INTO ticket_assignment_work_releases + VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at); +END; +CREATE TRIGGER ticket_active_worker_role_insert +BEFORE INSERT ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' + AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state NOT IN ('done', 'closed')) + AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id) + AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = NEW.workspace_id AND role = NEW.role + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND assignment_id != NEW.assignment_id) +BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END; +CREATE TRIGGER ticket_active_worker_role_update +BEFORE UPDATE ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' + AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id + AND ticket_id = NEW.ticket_id AND workflow_state NOT IN ('done', 'closed')) + AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases + WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id) + AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = NEW.workspace_id AND role = NEW.role + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND assignment_id != OLD.assignment_id) +BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END; +-- Keep archived responsibilities valid after Worker removal, but never allow +-- newly installed identities to refer to a removed / removal-reserved Worker. +CREATE TRIGGER ticket_active_worker_principal_insert +BEFORE INSERT ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' AND ( + NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id) + OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND state IN ('executing', 'failed', 'succeeded'))) +BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END; +CREATE TRIGGER ticket_active_worker_principal_update +BEFORE UPDATE ON ticket_current_worker_assignments +WHEN NEW.principal_kind = 'worker' AND ( + NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id) + OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id + AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id + AND state IN ('executing', 'failed', 'succeeded'))) +BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END; +CREATE TRIGGER ticket_unfinished_work_blocks_worker_delete +BEFORE DELETE ON worker_registry +WHEN EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id + AND worker_id = OLD.worker_id) +BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END; +CREATE TRIGGER ticket_unfinished_work_blocks_worker_move +BEFORE UPDATE OF runtime_id, worker_id, workspace_id ON worker_registry +WHEN (OLD.runtime_id != NEW.runtime_id OR OLD.worker_id != NEW.worker_id OR OLD.workspace_id != NEW.workspace_id) + AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments + WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id + AND worker_id = OLD.worker_id) +BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END; diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 8a60ffe40..818d71a21 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -1,6 +1,8 @@ use std::{path::PathBuf, sync::Arc}; -use chrono::{DateTime, Utc}; +#[cfg(test)] +use chrono::DateTime; +use chrono::Utc; use merge_request::{ MergeRequest, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, ReviewDecision, }; @@ -146,7 +148,7 @@ impl merge_request::AssignmentSource for AuthorityMergeRequestSource { ticket_id: &str, ) -> std::result::Result, String> { self.store - .get_current_ticket_coder_assignment(workspace_id, ticket_id) + .get_active_ticket_worker_assignment(workspace_id, ticket_id) .map(|assignment| { assignment.map(|assignment| merge_request::CurrentAssignment { assignment_id: assignment.assignment_id, @@ -173,14 +175,27 @@ impl merge_request::RepositorySource for AuthorityMergeRequestSource { } pub trait TicketMergeRevisionSource: Send + Sync { - fn resolve_subject_ref(&self, repository_id: &str, selector: &str) -> Option; + fn resolve_subject_ref( + &self, + ticket_id: &str, + repository_id: &str, + selector: &str, + ) -> std::result::Result; } struct UnresolvedTicketMergeRevisionSource; impl TicketMergeRevisionSource for UnresolvedTicketMergeRevisionSource { - fn resolve_subject_ref(&self, _repository_id: &str, _selector: &str) -> Option { - None + fn resolve_subject_ref( + &self, + _ticket_id: &str, + _repository_id: &str, + _selector: &str, + ) -> std::result::Result { + Err(server_api::MergeRequestRefDiagnostic { + code: "source_ref_runtime_unavailable".into(), + message: "No Runtime source observer is configured".into(), + }) } } @@ -437,100 +452,25 @@ impl SqliteWorkspaceAuthority { 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'))"; - let report_index = "(SELECT max(event.event_index) FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.kind='implementation_report')"; - let edit_index = "(SELECT max(event.event_index) FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.kind='item_edit')"; - let current_report = format!( - "({report_index} IS NOT NULL AND ({edit_index} IS NULL OR {report_index}>={edit_index}))" - ); - let merge_request_id = "(SELECT relation.merge_request_id FROM merge_request_ticket_relations relation - JOIN merge_requests request ON request.workspace_id=relation.workspace_id AND request.merge_request_id=relation.merge_request_id - WHERE relation.workspace_id=t.workspace_id AND relation.ticket_id=t.ticket_id - ORDER BY CASE WHEN request.state='open' THEN 0 ELSE 1 END, request.created_at DESC LIMIT 1)"; - let review_subject = format!( - "(SELECT json_extract(requested.payload_json,'$.subject_ref') - FROM merge_request_thread_events requested - WHERE requested.workspace_id=t.workspace_id - AND requested.merge_request_id={merge_request_id} - AND requested.kind='review_requested' - ORDER BY requested.sequence DESC LIMIT 1)" - ); - let review_decision = format!( - "(SELECT json_extract(event.payload_json,'$.decision') - FROM merge_request_thread_events event - WHERE event.workspace_id=t.workspace_id - AND event.merge_request_id={merge_request_id} AND event.kind='review' - AND json_extract(event.payload_json,'$.subject_ref')={review_subject} - AND NOT EXISTS (SELECT 1 FROM merge_request_thread_events revoked - WHERE revoked.workspace_id=event.workspace_id - AND revoked.merge_request_id=event.merge_request_id - AND revoked.kind='review_revoked' - AND json_extract(revoked.payload_json,'$.review_event_id')=event.event_id) - ORDER BY event.sequence DESC LIMIT 1)" - ); - let review_status = format!( - "CASE WHEN {merge_request_id} IS NULL THEN 'none' WHEN {review_decision}='approve' THEN 'approved' WHEN {review_decision}='request_changes' THEN 'request_changes' ELSE 'pending' END" - ); - let has_commit = format!( - "({review_subject} IS NOT NULL OR EXISTS (SELECT 1 FROM typed_ticket_event_references reference WHERE reference.workspace_id=t.workspace_id AND reference.ticket_id=t.ticket_id AND reference.kind='commit'))" - ); if !query.event_kinds.is_empty() { let event_kinds = query .event_kinds .iter() - .map(|event_kind| bind(SqlValue::Text(event_kind.clone()))) + .map(|kind| bind(SqlValue::Text(kind.clone()))) .collect::>(); predicates.push(format!("EXISTS (SELECT 1 FROM typed_ticket_events event WHERE event.workspace_id=t.workspace_id AND event.ticket_id=t.ticket_id AND event.kind IN ({}))", event_kinds.join(","))); } - for evidence in &query.evidence { - predicates.push(match evidence.as_str() { - "implementation_report" => format!("{report_index} IS NOT NULL"), - "implementation_report_after_rescope" => current_report.clone(), - "merge_request" => format!("{merge_request_id} IS NOT NULL"), - "commit" => has_commit.clone(), - "approved_review" => format!("{review_status}='approved'"), - other => { - return Err(Error::InvalidRecordId(format!( - "unsupported evidence filter `{other}`" - ))); - } - }); - } - if let Some(status) = &query.review_status { - let status = if matches!(status.as_str(), "unresolved_changes" | "changes_requested") { - "request_changes" - } else { - status.as_str() - }; - let status = bind(SqlValue::Text(status.to_string())); - predicates.push(format!("{review_status}={status}")); - } + // SQL only narrows immutable Ticket metadata. Review/evidence filters + // must use the same provider + complete linked-result projection as ShowTicket; + // a primary-MR/latest-request or implementation-report heuristic loses candidates. for attention in &query.attention { - predicates.push(match attention.as_str() { - "done_not_closed" => "t.workflow_state='done'".to_string(), - "implementation_report_not_closed" => { - format!("{report_index} IS NOT NULL AND t.workflow_state!='closed'") - } - "report_after_rescope" => current_report.clone(), - "unresolved_review" | "unresolved_changes" => { - format!("{review_status}='request_changes'") - } - "missing_commit" => format!("NOT {has_commit}"), - "blocked" => blocker.to_string(), - "unblocked" => format!("NOT {blocker}"), - "ready" => format!("t.workflow_state='ready' AND NOT {blocker}"), - "awaiting_review" => format!("{review_status}='pending'"), - "stale_after_rescope" => { - format!("{report_index} IS NOT NULL AND NOT {current_report}") - } - "missing_evidence" => format!( - "NOT ({current_report} AND {has_commit} AND {review_status}='approved')" - ), - other => { - return Err(Error::InvalidRecordId(format!( - "unsupported attention filter `{other}`" - ))); - } - }); + 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!( @@ -831,10 +771,15 @@ impl SqliteWorkspaceAuthority { }) }) .transpose()?; - let has_orchestrator = role_assignments + // Responsibility remains visible after work ends; action eligibility must + // use only active unfinished work, including after a Ticket is reopened. + let active_role_assignments = self + .store + .list_active_ticket_role_assignments(&self.workspace_id, id)?; + let has_orchestrator = active_role_assignments .iter() .any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator); - let has_coder = role_assignments + let has_coder = active_role_assignments .iter() .any(|assignment| assignment.role == TicketAssignmentRole::Coder); let targets = ticket @@ -856,27 +801,6 @@ impl SqliteWorkspaceAuthority { let write_repository_keys = ticket_write_target_repository_keys(&targets); let has_target = !write_repository_keys.is_empty(); let has_blockers = !ticket.relations.blockers.is_empty(); - let mut queue_assignment_blockers = Vec::new(); - for ticket_id in &dependency_check.queue_tickets { - let assignments = self - .store - .list_current_ticket_role_assignments(&self.workspace_id, ticket_id)?; - if !assignments - .iter() - .any(|assignment| assignment.role == TicketAssignmentRole::Orchestrator) - { - queue_assignment_blockers.push(format!( - "Ticket {ticket_id} requires an active Orchestrator assignment" - )); - } - if assignments - .iter() - .any(|assignment| assignment.role == TicketAssignmentRole::Coder) - { - queue_assignment_blockers - .push(format!("Ticket {ticket_id} has an active Coder assignment")); - } - } let mut assignment_diagnostics = Vec::new(); if let Some(legacy_assignee) = ticket .meta @@ -888,19 +812,7 @@ impl SqliteWorkspaceAuthority { "legacy Ticket assignee `{legacy_assignee}` is not assignment authority" )); } - let mut action_blockers = Vec::new(); - if !has_target { - action_blockers.push("Ticket target is required".to_string()); - } - if !dependency_check.queue_guard.can_queue_for_orchestrator { - if let Some(reason) = dependency_check.queue_guard.blocked_reason.clone() { - action_blockers.push(reason); - } else if let Some(reason) = dependency_check.queue_guard.reason.clone() { - action_blockers.push(reason); - } - } - let queue_assignments_valid = queue_assignment_blockers.is_empty(); - action_blockers.extend(queue_assignment_blockers); + let action_blockers = Vec::new(); let action_eligibility = TicketActionEligibility { can_assign_orchestrator: matches!( ticket.meta.workflow_state, @@ -912,12 +824,7 @@ impl SqliteWorkspaceAuthority { ticket.meta.workflow_state, TicketWorkflowState::Planning | TicketWorkflowState::Ready ), - can_queue: ticket.meta.workflow_state == TicketWorkflowState::Ready - && has_orchestrator - && !has_coder - && has_target - && dependency_check.queue_guard.can_queue_for_orchestrator - && queue_assignments_valid, + can_queue: true, can_start_manual_coder: ticket.meta.workflow_state == TicketWorkflowState::Ready && !has_orchestrator && !has_coder @@ -926,31 +833,49 @@ impl SqliteWorkspaceAuthority { queue_tickets: dependency_check.queue_tickets.clone(), blockers: action_blockers, }; - let merge_requests = self + let requests = self .merge_request_store .list_for_ticket(&self.workspace_id, id) - .map_err(|error| Error::Store(error.to_string()))? - .into_iter() + .map_err(|error| Error::Store(error.to_string()))?; + let merge_requests = requests + .iter() .map(|request| { - let current_subject_ref = request.selector_from.as_deref().and_then(|selector| { - self.merge_revision_source - .resolve_subject_ref(&request.repository_id, selector) - }); + // Integrated results never require observation of a mutable branch. + let observation = if request.state == MergeRequestState::Open { + request.selector_from.as_deref().map_or_else( + || { + Err(server_api::MergeRequestRefDiagnostic { + code: "source_ref_selector_missing".into(), + message: "Open Merge Request has no source selector".into(), + }) + }, + |selector| { + self.merge_revision_source.resolve_subject_ref( + id, + &request.repository_id, + selector, + ) + }, + ) + } else { + Err(server_api::MergeRequestRefDiagnostic { + code: "not_required".into(), + message: String::new(), + }) + }; let repository_key = self .store .get_repository(&self.workspace_id, &request.repository_id)? .map(|repository| repository.repository_key) .ok_or_else(|| Error::UnknownRepository(request.repository_id.clone()))?; Ok(merge_request_summary( - request, + request.clone(), repository_key, - current_subject_ref, + observation, )) }) .collect::>>()?; let merge_request = (merge_requests.len() == 1).then(|| merge_requests[0].clone()); - let evidence = - ticket_evidence_summary(&write_repository_keys, &ticket.events, &merge_requests); let item_revision = ticket .events .iter() @@ -959,6 +884,13 @@ impl SqliteWorkspaceAuthority { .and_then(|event| event.attributes.get("event_id").cloned()) .or_else(|| ticket.meta.updated_at.clone()) .unwrap_or_else(|| format!("{}:0", ticket.meta.id)); + let requirement_approved = + ticket_requirement_approved(&requests, &merge_requests, &item_revision); + let evidence = ticket_evidence_summary( + &write_repository_keys, + &merge_requests, + requirement_approved, + ); let resource_key = ticket .meta .resource_key @@ -1141,10 +1073,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .transpose()?; let candidate_limit = limit.saturating_add(1); let candidate_ids = - self.query_ticket_candidate_ids(&query, sort, cursor.as_ref(), candidate_limit)?; - let source_truncated = candidate_ids.len() == candidate_limit; + self.query_ticket_candidate_ids(&query, sort, cursor.as_ref(), candidate_limit + 1)?; + let source_truncated = candidate_ids.len() > candidate_limit; let mut items = Vec::new(); - for ticket_id in candidate_ids { + let mut last_scanned_cursor = None; + for ticket_id in candidate_ids.into_iter().take(candidate_limit) { let authoritative = self .ticket_backend .show(TicketIdOrSlug::Id(ticket_id.clone()))?; @@ -1159,35 +1092,43 @@ impl TicketAuthority for SqliteWorkspaceAuthority { }, &self.ticket_backend, )?; - if ticket_matches_query( + let matches = ticket_matches_query( &summary, &detail, authoritative_body.as_str(), &authoritative_events, &query, - ) { - items.push(ticket_query_item( - summary, - &detail, - authoritative_body.as_str(), - &authoritative_events, - &query, - )); + ); + let item = ticket_query_item( + summary, + &detail, + authoritative_body.as_str(), + &authoritative_events, + &query, + ); + last_scanned_cursor = Some(make_ticket_cursor(&item, sort, &fingerprint)); + if matches { + items.push(item); } } sort_ticket_query_items(&mut items, sort); if let Some(cursor) = cursor { items.retain(|item| ticket_item_after_cursor(item, sort, &cursor)); } - let has_more = items.len() > limit; + let matches_truncated = items.len() > limit; + let has_more = matches_truncated || source_truncated; items.truncate(limit); - let next_cursor = has_more - .then(|| { - items - .last() - .map(|item| make_ticket_cursor(item, sort, &fingerprint)) - }) - .flatten(); + // A bounded scan can contain no matches. Continue after the last scanned + // candidate rather than falsely reporting exhaustion or losing later matches. + let next_cursor = if matches_truncated { + items + .last() + .map(|item| make_ticket_cursor(item, sort, &fingerprint)) + } else if source_truncated { + last_scanned_cursor + } else { + None + }; Ok(TicketQueryResponse { page: QueryPage { limit, @@ -1766,15 +1707,52 @@ fn ticket_evidence_event(sequence: usize, event: &TicketEvent) -> TicketEvidence pub(crate) fn merge_request_summary( request: MergeRequest, repository_key: String, - current_subject_ref: Option, + source_observation: std::result::Result, ) -> TicketMergeRequestSummary { + let (current_subject_ref, source_ref_observation, integration_evidence_error) = + if request.state == MergeRequestState::Merged { + ( + request + .merged_result() + .ok() + .map(|merge| merge.approved_source_ref.clone()), + server_api::TicketSourceRefObservation::NotRequired {}, + request + .integration_approval() + .err() + .map(|error| error.code().to_string()), + ) + } else if request.state == MergeRequestState::Closed { + ( + None, + server_api::TicketSourceRefObservation::NotRequired {}, + None, + ) + } else { + match source_observation { + Ok(subject) => ( + Some(subject), + server_api::TicketSourceRefObservation::Observed {}, + None, + ), + Err(error) => ( + None, + server_api::TicketSourceRefObservation::Unavailable { code: error.code }, + None, + ), + } + }; let latest_review_request = request.thread.iter().rev().find_map(|event| match event { MergeRequestThreadEvent::ReviewRequested(review) => Some(review), _ => None, }); - let current_review = current_subject_ref - .as_deref() - .and_then(|subject_ref| request.effective_review(subject_ref)); + let current_review = if request.state == MergeRequestState::Merged { + request.integration_approval().ok() + } else { + current_subject_ref + .as_deref() + .and_then(|subject_ref| request.effective_review(subject_ref)) + }; let current_review_request = current_review .and_then(|review| { request.thread.iter().find_map(|event| match event { @@ -1798,11 +1776,19 @@ pub(crate) fn merge_request_summary( }) }) }); - let review_status = match current_review.map(|review| &review.decision) { - Some(ReviewDecision::Approve) => "approved", - Some(ReviewDecision::RequestChanges) => "changes_requested", - None if latest_review_request.is_some() => "pending", - None => "none", + let review_status = if integration_evidence_error.is_some() + || matches!( + source_ref_observation, + server_api::TicketSourceRefObservation::Unavailable { .. } + ) { + "unknown" + } else { + match current_review.map(|review| &review.decision) { + Some(ReviewDecision::Approve) => "approved", + Some(ReviewDecision::RequestChanges) => "changes_requested", + None if latest_review_request.is_some() => "pending", + None => "none", + } } .to_string(); let state = match request.state { @@ -1821,7 +1807,11 @@ pub(crate) fn merge_request_summary( selector_to: request.selector_to.clone(), updated_at: request.updated_at.to_rfc3339(), current_subject_ref, - review_subject_ref: latest_review_request.map(|review| review.subject_ref.clone()), + source_ref_observation, + integration_evidence_error, + review_subject_ref: current_review_request + .or(latest_review_request) + .map(|review| review.subject_ref.clone()), review_requested_at: current_review_request.map(|review| review.created_at.to_rfc3339()), review_submitted_at: current_review.map(|review| review.created_at.to_rfc3339()), review_excerpt: current_review.map(|review| truncate_body(&review.body, 240).0), @@ -1843,14 +1833,6 @@ fn substantive_item_edit(event: &TicketEvent) -> bool { .any(|field| matches!(field, "title" | "body" | "target" | "targets")) } -fn event_timestamp(event: &TicketEvent) -> Option> { - event - .at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) -} - fn ticket_write_target_repository_keys(targets: &[ticket::TicketTarget]) -> Vec<&str> { targets .iter() @@ -1859,102 +1841,104 @@ fn ticket_write_target_repository_keys(targets: &[ticket::TicketTarget]) -> Vec< .collect() } +fn ticket_requirement_approved( + requests: &[MergeRequest], + summaries: &[TicketMergeRequestSummary], + item_revision: &str, +) -> bool { + let subjects = summaries + .iter() + .map(|request| { + request.current_subject_ref.as_ref().map(|subject_ref| { + merge_request::MergeRequestReviewSubject { + merge_request_id: request.merge_request_id.clone(), + subject_ref: subject_ref.clone(), + } + }) + }) + .collect::>>(); + subjects.is_some_and(|subjects| { + merge_request::requirement_approval(requests, item_revision, &subjects, None).is_ok() + }) +} + fn ticket_evidence_summary( - ticket_write_repository_keys: &[&str], - events: &[TicketEvent], + _ticket_write_repository_keys: &[&str], merge_requests: &[TicketMergeRequestSummary], + requirement_approved: bool, ) -> TicketEvidenceSummary { let relevant = merge_requests .iter() - .filter(|request| { - request.state != "closed" - && ticket_write_repository_keys.contains(&request.repository_key.as_str()) - }) + .filter(|request| request.state != "closed") .collect::>(); let has_merge_request = !relevant.is_empty(); let has_current_subject_ref = has_merge_request && relevant.iter().all(|request| { - request.state == "merged" - || request - .current_subject_ref - .as_deref() - .is_some_and(|subject_ref| !subject_ref.is_empty()) + request + .current_subject_ref + .as_deref() + .is_some_and(|subject| !subject.is_empty()) }); let has_review_request = has_merge_request - && relevant.iter().all(|request| { - request.state == "merged" - || request - .review_subject_ref - .as_deref() - .is_some_and(|subject_ref| !subject_ref.is_empty()) - }); + && relevant + .iter() + .all(|request| request.review_subject_ref.is_some()); let has_commit = has_current_subject_ref; let unresolved_request_changes = relevant .iter() .any(|request| request.review_status == "changes_requested"); let approved_current_subject = has_merge_request - && relevant - .iter() - .all(|request| request.state == "merged" || request.review_status == "approved"); + && relevant.iter().all(|request| { + request.review_status == "approved" && request.integration_evidence_error.is_none() + }); + let observation_unavailable = relevant.iter().any(|request| { + matches!( + request.source_ref_observation, + server_api::TicketSourceRefObservation::Unavailable { .. } + ) + }); + // Exact revision/snapshot attestation, not a timestamp heuristic. + let review_after_rescope = approved_current_subject && requirement_approved; let review_status = if !has_merge_request { None + } else if observation_unavailable + || relevant + .iter() + .any(|request| request.integration_evidence_error.is_some()) + { + Some("unknown".into()) } else if unresolved_request_changes { - Some("changes_requested".to_string()) - } else if approved_current_subject { - Some("approved".to_string()) + Some("changes_requested".into()) + } else if review_after_rescope { + Some("approved".into()) } else { - Some("pending".to_string()) + Some("pending".into()) }; - let latest_rescope = events - .iter() - .filter(|event| substantive_item_edit(event)) - .last(); - let review_after_rescope = approved_current_subject - && relevant.iter().all(|request| { - latest_rescope.is_none_or(|rescope| { - let Some(rescope_at) = event_timestamp(rescope) else { - return false; - }; - let Some(requested_at) = request - .review_requested_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - else { - return false; - }; - let Some(reviewed_at) = request - .review_submitted_at - .as_deref() - .and_then(|value| DateTime::parse_from_rfc3339(value).ok()) - .map(|value| value.with_timezone(&Utc)) - else { - return false; - }; - requested_at > rescope_at && reviewed_at > rescope_at - }) - }); - let mut missing = Vec::new(); - if !has_merge_request { - missing.push("merge_request".to_string()); + if observation_unavailable { + missing.push("source_ref_unavailable".into()); } - if !has_current_subject_ref { - missing.push("current_subject_ref".to_string()); + if relevant + .iter() + .any(|request| request.integration_evidence_error.is_some()) + { + missing.push("integration_evidence".into()); } - if !has_commit { - missing.push("commit".to_string()); + if has_merge_request && !has_current_subject_ref { + missing.push("current_subject_ref".into()); + } + if has_merge_request && !has_commit { + missing.push("commit".into()); } if unresolved_request_changes { - missing.push("unresolved_request_changes".to_string()); + missing.push("unresolved_request_changes".into()); } - if !approved_current_subject { - missing.push("approved_current_subject".to_string()); + if has_merge_request && !approved_current_subject { + missing.push("approved_current_subject".into()); } - if approved_current_subject && !review_after_rescope { - missing.push("review_after_rescope".to_string()); + if approved_current_subject && !requirement_approved { + missing.push("review_after_rescope".into()); } - TicketEvidenceSummary { has_merge_request, has_current_subject_ref, @@ -1964,7 +1948,7 @@ fn ticket_evidence_summary( approved_current_subject, review_after_rescope, unresolved_request_changes, - complete_for_integration: missing.is_empty(), + complete_for_integration: has_merge_request && missing.is_empty(), missing, } } @@ -2010,6 +1994,7 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { && !matches!( status, "none" + | "unknown" | "pending" | "approved" | "request_changes" @@ -2143,7 +2128,7 @@ fn ticket_evidence_matches(evidence: &TicketEvidenceSummary, filter: &str) -> bo match filter { "merge_request" => evidence.has_merge_request, "commit" => evidence.has_commit, - "approved_review" => evidence.approved_current_subject, + "approved_review" => evidence.review_after_rescope, _ => false, } } @@ -2167,9 +2152,13 @@ fn ticket_attention_matches( } "unresolved_changes" => evidence.unresolved_request_changes, "stale_after_rescope" => { - authoritative_events.iter().any(substantive_item_edit) && !evidence.review_after_rescope + authoritative_events.iter().any(substantive_item_edit) + && evidence + .missing + .iter() + .any(|reason| reason == "review_after_rescope") } - "missing_evidence" => !evidence.complete_for_integration, + "missing_evidence" => evidence.has_merge_request && !evidence.complete_for_integration, _ => false, } } @@ -2877,255 +2866,333 @@ mod tests { } } - #[test] - fn merge_request_summary_uses_the_provider_resolved_current_subject() { - let approved = merge_request_summary( - reviewed_merge_request(ReviewDecision::Approve, false), - "main".to_string(), - Some("commit-1".to_string()), - ); - assert_eq!(approved.review_status, "approved"); - assert_eq!(approved.current_subject_ref.as_deref(), Some("commit-1")); - assert_eq!( - approved.review_requested_at.as_deref(), - Some("2026-01-01T00:03:00+00:00") - ); + fn unavailable_source( + code: &str, + ) -> std::result::Result { + Err(server_api::MergeRequestRefDiagnostic { + code: code.into(), + message: "Provider unavailable".into(), + }) + } - let moved = merge_request_summary( - reviewed_merge_request(ReviewDecision::Approve, false), - "main".to_string(), - Some("commit-2".to_string()), - ); - assert_eq!(moved.review_status, "pending"); - assert_eq!(moved.current_subject_ref.as_deref(), Some("commit-2")); - assert_eq!(moved.review_subject_ref.as_deref(), Some("commit-1")); - assert_eq!(moved.review_submitted_at, None); + fn merged_request() -> MergeRequest { + let mut request = reviewed_merge_request(ReviewDecision::Approve, false); + request.state = MergeRequestState::Merged; + request + .thread + .push(MergeRequestThreadEvent::Merge(merge_request::MergeEvent { + event_id: "merge-1".into(), + sequence: 3, + operation_id: "operation-1".into(), + approval_event_id: "review-1".into(), + approved_source_ref: "commit-1".into(), + target_ref_before: "target-1".into(), + target_ref_after: "commit-1".into(), + strategy: merge_request::MergeStrategy::FastForward, + resolution: merge_request::ConflictResolution::None, + merged_by: actor(), + created_at: request.updated_at, + })); + request + } + + fn project_evidence( + requests: &[MergeRequest], + revision: &str, + observations: &[std::result::Result], + ) -> (Vec, TicketEvidenceSummary) { + let summaries = requests + .iter() + .zip(observations) + .map(|(request, observation)| { + merge_request_summary( + request.clone(), + request.repository_id.clone(), + observation.clone(), + ) + }) + .collect::>(); + let approved = ticket_requirement_approved(requests, &summaries, revision); + let evidence = ticket_evidence_summary(&["main", "other"], &summaries, approved); + (summaries, evidence) } #[test] - fn ticket_target_projection_accepts_multiple_write_targets() { - let target = |repository_key: &str, access| ticket::TicketTarget { - repository_key: repository_key.to_string(), - ref_selector: Some("develop".to_string()), - access, - }; - - assert!(ticket_write_target_repository_keys(&[]).is_empty()); - assert!( - ticket_write_target_repository_keys(&[target( - "docs", - ticket::TicketTargetAccess::ReadOnly, - )]) - .is_empty() - ); - - let one_write = [ - target("main", ticket::TicketTargetAccess::ReadWrite), - target("docs", ticket::TicketTargetAccess::ReadOnly), - ]; - assert_eq!( - ticket_write_target_repository_keys(&one_write), - vec!["main"] - ); - - let two_writes = [ - target("main", ticket::TicketTargetAccess::ReadWrite), - target("docs", ticket::TicketTargetAccess::ReadWrite), - ]; - assert_eq!( - ticket_write_target_repository_keys(&two_writes), - vec!["main", "docs"] - ); + fn open_source_movement_changes_and_revocation_fail_closed() { + for (decision, revoked, source, approved, changes) in [ + (ReviewDecision::Approve, false, "commit-1", true, false), + (ReviewDecision::Approve, false, "commit-2", false, false), + (ReviewDecision::Approve, true, "commit-1", false, false), + ( + ReviewDecision::RequestChanges, + false, + "commit-1", + false, + true, + ), + ] { + let (summaries, evidence) = project_evidence( + &[reviewed_merge_request(decision, revoked)], + "revision-1", + &[Ok(source.into())], + ); + assert_eq!(evidence.complete_for_integration, approved); + assert_eq!(evidence.unresolved_request_changes, changes); + assert_eq!(summaries[0].current_subject_ref.as_deref(), Some(source)); + assert_eq!( + ticket_evidence_matches(&evidence, "approved_review"), + approved + ); + } } #[test] - fn ticket_evidence_is_tied_only_to_the_write_target() { - let read_only_request = merge_request_summary( - reviewed_merge_request(ReviewDecision::Approve, false), - "docs".to_string(), - Some("commit-1".to_string()), - ); - let evidence = - ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&read_only_request)); - - assert!(!evidence.has_merge_request); - assert!(!evidence.complete_for_integration); - assert!(evidence.missing.contains(&"merge_request".to_string())); - } - - #[test] - fn ticket_readiness_requires_current_unrevoked_approval_without_a_report() { - let approved = merge_request_summary( - reviewed_merge_request(ReviewDecision::Approve, false), - "main".to_string(), - Some("commit-1".to_string()), - ); - let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&approved)); - assert!(evidence.complete_for_integration); - assert!(evidence.approved_current_subject); - assert!(evidence.review_after_rescope); - assert!(evidence.has_commit); - - let with_audit_events = ticket_evidence_summary( - &["main"], - &[ - ticket_event("comment", "2026-01-01T00:05:00Z", None), - ticket_event("implementation_report", "2026-01-01T00:06:00Z", None), - ], - std::slice::from_ref(&approved), - ); - assert!(with_audit_events.complete_for_integration); - - let revoked = merge_request_summary( - reviewed_merge_request(ReviewDecision::Approve, true), - "main".to_string(), - Some("commit-1".to_string()), - ); - let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&revoked)); - assert!(!evidence.approved_current_subject); - assert!(!evidence.complete_for_integration); - - let changes = merge_request_summary( - reviewed_merge_request(ReviewDecision::RequestChanges, false), - "main".to_string(), - Some("commit-1".to_string()), - ); - let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&changes)); - assert!(evidence.unresolved_request_changes); - assert!(!evidence.complete_for_integration); - } - - #[test] - fn ticket_readiness_fails_closed_for_missing_or_closed_current_merge_request() { - let unresolved = merge_request_summary( - reviewed_merge_request(ReviewDecision::Approve, false), - "main".to_string(), - None, - ); - let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&unresolved)); - assert!(!evidence.has_current_subject_ref); - assert!(!evidence.has_commit); - assert!(!evidence.complete_for_integration); - - let mut closed_request = reviewed_merge_request(ReviewDecision::Approve, false); - closed_request.state = MergeRequestState::Closed; - let closed = merge_request_summary( - closed_request, - "main".to_string(), - Some("commit-1".to_string()), - ); - let evidence = ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&closed)); - assert!(!evidence.has_merge_request); - assert!(!evidence.complete_for_integration); - assert!(evidence.missing.contains(&"merge_request".to_string())); - } - - #[test] - fn ticket_readiness_requires_request_and_approval_after_substantive_rescope() { - let approved = merge_request_summary( - reviewed_merge_request(ReviewDecision::Approve, false), - "main".to_string(), - Some("commit-1".to_string()), - ); - let fresh = ticket_evidence_summary( - &["main"], - &[ticket_event( - "item_edit", - "2026-01-01T00:02:00Z", - Some("body"), - )], - std::slice::from_ref(&approved), - ); - assert!(fresh.review_after_rescope); - assert!(fresh.complete_for_integration); - - let stale = ticket_evidence_summary( - &["main"], - &[ticket_event( - "item_edit", - "2026-01-01T00:05:00Z", - Some("title"), - )], - std::slice::from_ref(&approved), - ); - assert!(!stale.review_after_rescope); - assert!(!stale.complete_for_integration); - assert!(stale.missing.contains(&"review_after_rescope".to_string())); - - let metadata_only = ticket_evidence_summary( - &["main"], - &[ticket_event( - "item_edit", - "2026-01-01T00:05:00Z", - Some("formatter"), - )], - std::slice::from_ref(&approved), - ); - assert!(metadata_only.complete_for_integration); - } - - #[test] - fn ticket_query_filters_map_to_current_merge_request_evidence() { - let approved_summary = merge_request_summary( - reviewed_merge_request(ReviewDecision::Approve, false), - "main".to_string(), - Some("commit-1".to_string()), - ); - let approved = - ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&approved_summary)); - assert!(ticket_evidence_matches(&approved, "merge_request")); - assert!(ticket_evidence_matches(&approved, "commit")); - assert!(ticket_evidence_matches(&approved, "approved_review")); - assert!(!ticket_attention_matches( - "inprogress", - &approved, - false, - &[], - "missing_evidence", - )); - - let pending_summary = merge_request_summary( - reviewed_merge_request(ReviewDecision::Approve, false), - "main".to_string(), - Some("commit-2".to_string()), - ); - let pending = - ticket_evidence_summary(&["main"], &[], std::slice::from_ref(&pending_summary)); - assert!(ticket_attention_matches( - "inprogress", - &pending, - false, - &[], - "awaiting_review", - )); - assert!(!ticket_evidence_matches(&pending, "approved_review")); - - let stale_events = vec![ticket_event( + fn unobservable_open_source_is_unknown_not_stale_after_rescope() { + let events = [ticket_event( "item_edit", "2026-01-01T00:05:00Z", - Some("targets"), + Some("body"), )]; - let stale = ticket_evidence_summary( - &["main"], - &stale_events, - std::slice::from_ref(&approved_summary), + let (summaries, evidence) = project_evidence( + &[reviewed_merge_request(ReviewDecision::Approve, false)], + "revision-2", + &[unavailable_source("source_ref_provider_timeout")], ); - assert!(ticket_attention_matches( - "inprogress", - &stale, + assert!( + matches!(&summaries[0].source_ref_observation, server_api::TicketSourceRefObservation::Unavailable { code } if code == "source_ref_provider_timeout") + ); + assert_eq!(evidence.review_status.as_deref(), Some("unknown")); + assert!(evidence.missing.contains(&"source_ref_unavailable".into())); + assert!(!evidence.missing.contains(&"review_after_rescope".into())); + assert!(!ticket_attention_matches( + "done", + &evidence, false, - &stale_events, - "stale_after_rescope", + &events, + "stale_after_rescope" )); assert!(ticket_attention_matches( - "inprogress", + "done", + &evidence, + false, + &events, + "missing_evidence" + )); + assert!(!ticket_evidence_matches(&evidence, "approved_review")); + } + + #[test] + fn merged_evidence_uses_immutable_result_despite_deleted_moved_or_unavailable_source() { + for observation in [ + Ok("moved-source".into()), + unavailable_source("source_ref_not_found"), + unavailable_source("source_ref_provider_unavailable"), + unavailable_source("source_ref_runtime_unavailable"), + ] { + let (summaries, evidence) = + project_evidence(&[merged_request()], "revision-1", &[observation]); + assert_eq!( + summaries[0].current_subject_ref.as_deref(), + Some("commit-1") + ); + assert_eq!( + summaries[0].source_ref_observation, + server_api::TicketSourceRefObservation::NotRequired {} + ); + assert_eq!( + summaries[0].review_submitted_at.as_deref(), + Some("2026-01-01T00:04:00+00:00") + ); + assert!(evidence.complete_for_integration, "{:?}", evidence.missing); + assert!(ticket_evidence_matches(&evidence, "approved_review")); + assert!(!ticket_attention_matches( + "done", + &evidence, + false, + &[], + "missing_evidence" + )); + } + } + + #[test] + fn merged_state_does_not_create_missing_mismatched_or_revoked_approval() { + for case in ["merge", "approval", "source", "request", "revoked"] { + let mut request = merged_request(); + match case { + "merge" => request + .thread + .retain(|event| !matches!(event, MergeRequestThreadEvent::Merge(_))), + "approval" => request + .thread + .retain(|event| !matches!(event, MergeRequestThreadEvent::Review(_))), + "request" => request + .thread + .retain(|event| !matches!(event, MergeRequestThreadEvent::ReviewRequested(_))), + "source" => { + if let MergeRequestThreadEvent::Merge(merge) = + request.thread.last_mut().unwrap() + { + merge.approved_source_ref = "wrong-source".into(); + } + } + "revoked" => request.thread.extend( + reviewed_merge_request(ReviewDecision::Approve, true) + .thread + .into_iter() + .filter(|event| matches!(event, MergeRequestThreadEvent::ReviewRevoked(_))), + ), + _ => unreachable!(), + } + let (summaries, evidence) = project_evidence( + &[request], + "revision-1", + &[unavailable_source("source_ref_not_found")], + ); + assert!(summaries[0].integration_evidence_error.is_some(), "{case}"); + assert!(!evidence.approved_current_subject, "{case}"); + assert!(!evidence.complete_for_integration, "{case}"); + assert!( + evidence.missing.contains(&"integration_evidence".into()), + "{case}" + ); + } + } + + fn approve_snapshot( + request: &mut MergeRequest, + revision: &str, + subjects: Vec, + ) { + let sequence = request + .thread + .iter() + .map(|event| event.sequence()) + .max() + .unwrap_or(0) + + 1; + let mut reviewed = reviewed_merge_request(ReviewDecision::Approve, false); + for event in &mut reviewed.thread { + match event { + MergeRequestThreadEvent::ReviewRequested(review) => { + review.event_id = format!("request-{sequence}"); + review.sequence = sequence; + review.ticket_item_revision = revision.into(); + review.ticket_merge_request_subjects = subjects.clone(); + } + MergeRequestThreadEvent::Review(review) => { + review.event_id = format!("review-{sequence}"); + review.request_event_id = format!("request-{sequence}"); + review.sequence = sequence + 1; + review.ticket_item_revision = revision.into(); + review.ticket_merge_request_subjects = subjects.clone(); + } + _ => unreachable!(), + } + } + request.thread.extend(reviewed.thread); + } + + #[test] + fn exact_revision_and_multi_result_snapshot_require_fresh_attestation_not_later_dates() { + let mut requests = vec![ + merged_request(), + reviewed_merge_request(ReviewDecision::Approve, false), + ]; + requests[1].merge_request_id = "mr-2".into(); + requests[1].repository_id = "other".into(); + let observations = [ + unavailable_source("source_ref_not_found"), + Ok("commit-1".into()), + ]; + let (_, stale) = project_evidence(&requests, "revision-1", &observations); + assert!(stale.approved_current_subject); + assert!(!stale.review_after_rescope); // linked set grew, even though dates did not change + assert!(!ticket_evidence_matches(&stale, "approved_review")); + let snapshot = vec![ + merge_request::MergeRequestReviewSubject { + merge_request_id: "mr-1".into(), + subject_ref: "commit-1".into(), + }, + merge_request::MergeRequestReviewSubject { + merge_request_id: "mr-2".into(), + subject_ref: "commit-1".into(), + }, + ]; + approve_snapshot(&mut requests[0], "revision-2", snapshot); + let (summaries, fresh) = project_evidence(&requests, "revision-2", &observations); + assert!(fresh.complete_for_integration); + assert_eq!(summaries[0].review_excerpt.as_deref(), Some("review body")); + assert!( + !project_evidence(&requests, "revision-3", &observations) + .1 + .complete_for_integration + ); + // Complete the second result: the exact approved source set is unchanged. + let mut other_result = merged_request(); + if let MergeRequestThreadEvent::Merge(merge) = other_result.thread.pop().unwrap() { + requests[1] + .thread + .push(MergeRequestThreadEvent::Merge(merge)); + } + requests[1].state = MergeRequestState::Merged; + assert!( + project_evidence(&requests, "revision-2", &observations) + .1 + .complete_for_integration + ); + assert!( + !project_evidence(&requests[..1], "revision-2", &observations[..1]) + .1 + .complete_for_integration + ); + let events = [ticket_event( + "item_edit", + "2026-01-01T00:00:00Z", + Some("body"), + )]; + let stale = project_evidence(&requests, "revision-3", &observations).1; + assert!(ticket_attention_matches( + "done", &stale, false, - &stale_events, - "missing_evidence", + &events, + "stale_after_rescope" )); } + #[test] + fn write_targets_are_authorized_not_mandatory_and_closed_requests_are_not_ready() { + let target = |repository_key: &str, access| ticket::TicketTarget { + repository_key: repository_key.into(), + ref_selector: Some("develop".into()), + access, + }; + assert_eq!( + ticket_write_target_repository_keys(&[ + target("main", ticket::TicketTargetAccess::ReadWrite), + target("docs", ticket::TicketTargetAccess::ReadOnly) + ]), + vec!["main"] + ); + let (summaries, _) = project_evidence( + &[merged_request()], + "revision-1", + &[unavailable_source("unused")], + ); + assert!( + ticket_evidence_summary(&["main", "other"], &summaries, true).complete_for_integration + ); + assert!(ticket_evidence_summary(&["docs"], &summaries, true).has_merge_request); + let mut closed = reviewed_merge_request(ReviewDecision::Approve, false); + closed.state = MergeRequestState::Closed; + assert!( + !project_evidence(&[closed], "revision-1", &[Ok("commit-1".into())]) + .1 + .complete_for_integration + ); + } + #[tokio::test] async fn sqlite_workspace_authority_reads_sqlite_records_without_filesystem_authority() { let dir = tempfile::tempdir().unwrap(); @@ -3302,19 +3369,16 @@ VALUES ('workspace-test', 'ticket', 4); assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket"); assert_eq!(tickets.items[0].id, "00000000001J2"); assert_eq!(tickets.items[0].state, "ready"); - assert_eq!(tickets.items[0].workspace_action_priority, "background"); + assert_eq!( + tickets.items[0].workspace_action_priority, + "ready_for_queue" + ); let ticket_by_key = authority.ticket(&tickets.items[0].resource_key).unwrap(); assert_eq!(ticket_by_key.id, tickets.items[0].id); let ticket = authority.ticket("00000000001J2").unwrap(); - assert!(!ticket.action_eligibility.can_queue); - assert!( - ticket - .action_eligibility - .blockers - .iter() - .any(|reason| reason.contains("00000000001J6")) - ); + assert!(ticket.action_eligibility.can_queue); + assert!(ticket.action_eligibility.blockers.is_empty()); assert!(ticket.body.contains("Ticket body")); assert!(ticket.body_truncated); assert!(!ticket.body.contains("Deep Ticket marker")); diff --git a/crates/workspace-server/src/latest_schema.sql b/crates/workspace-server/src/latest_schema.sql index 64275a3ab..3e9db8685 100644 --- a/crates/workspace-server/src/latest_schema.sql +++ b/crates/workspace-server/src/latest_schema.sql @@ -384,9 +384,6 @@ CREATE TABLE ticket_current_worker_assignments ( FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE, - FOREIGN KEY (workspace_id, runtime_id, worker_id) - REFERENCES worker_registry(workspace_id, runtime_id, worker_id) - ON DELETE RESTRICT, CHECK( (principal_kind = 'worker' AND runtime_id IS NOT NULL AND worker_id IS NOT NULL AND principal_id IS NULL) OR @@ -1219,9 +1216,6 @@ CREATE INDEX ticket_current_principal_idx CREATE UNIQUE INDEX ticket_current_singleton_role_idx ON ticket_current_worker_assignments(workspace_id, ticket_id, role) WHERE role IN ('orchestrator', 'coder'); -CREATE UNIQUE INDEX ticket_current_worker_role_idx - ON ticket_current_worker_assignments(workspace_id, role, runtime_id, worker_id) - WHERE principal_kind = '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/repositories.rs b/crates/workspace-server/src/repositories.rs index 69840f65e..9da499791 100644 --- a/crates/workspace-server/src/repositories.rs +++ b/crates/workspace-server/src/repositories.rs @@ -75,18 +75,6 @@ impl RepositoryRegistryReader { } pub fn list(&self) -> RepositoryListProjection { - if self.repositories.is_empty() { - return RepositoryListProjection { - items: Vec::new(), - diagnostics: vec![Diagnostic { - severity: DiagnosticSeverity::Warning, - code: "repository_config_empty".to_string(), - message: "No repositories are configured for this workspace backend." - .to_string(), - }], - }; - } - RepositoryListProjection { items: self .repositories @@ -598,12 +586,11 @@ mod tests { } #[test] - fn empty_registry_reports_diagnostic_without_implicit_repository() { + fn empty_registry_is_valid_without_warning_or_implicit_repository() { let projection = RepositoryRegistryReader::new(Vec::new()).list(); assert!(projection.items.is_empty()); - assert_eq!(projection.diagnostics.len(), 1); - assert_eq!(projection.diagnostics[0].code, "repository_config_empty"); + assert!(projection.diagnostics.is_empty()); } #[test] diff --git a/crates/workspace-server/src/retention.rs b/crates/workspace-server/src/retention.rs index bb36636f8..34cf2c671 100644 --- a/crates/workspace-server/src/retention.rs +++ b/crates/workspace-server/src/retention.rs @@ -69,7 +69,7 @@ pub struct WorkerRemovalPlanRequest { #[serde(tag = "kind", rename_all = "snake_case")] pub enum WorkerRemovalBlocker { Hold, - CurrentAssignment { + UnfinishedWork { assignment_id: String, ticket_id: String, }, @@ -229,8 +229,8 @@ impl SqliteWorkspaceStore { }; let mut blockers=Vec::new(); if worker.retention_state=="pinned" { blockers.push(WorkerRemovalBlocker::Hold); } - if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? { - blockers.push(WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id}); + if let Some((assignment_id,ticket_id))=tx.query_row("SELECT a.assignment_id,a.ticket_id FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3",params![req.workspace_id,req.worker.runtime_id,req.worker.worker_id],|r|Ok((r.get(0)?,r.get(1)?))).optional()? { + blockers.push(WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id}); } let fp=fingerprint(req,&worker.updated_at,inv,&policy,&blockers)?; let plan_id=stable("wrp",&fp); let operation_id=stable("wro",&fp); @@ -276,10 +276,10 @@ impl SqliteWorkspaceStore { mark_stale(&tx,&plan,"hold added")?;tx.commit()?; return Err(stale_error(&plan,"hold added")); } - let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?; + let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|r|r.get(0))?; if assigned{ - mark_stale(&tx,&plan,"current assignment added")?;tx.commit()?; - return Err(stale_error(&plan,"current assignment added")); + mark_stale(&tx,&plan,"unfinished work added")?;tx.commit()?; + return Err(stale_error(&plan,"unfinished work added")); } let now=Utc::now().to_rfc3339(); tx.execute("UPDATE worker_removal_operations SET state='executing',updated_at=?1 WHERE operation_id=?2",params![now,plan.operation_id])?; @@ -462,8 +462,8 @@ impl SqliteWorkspaceStore { let worker=load_worker(&tx,workspace_id,&plan.worker)?.ok_or_else(||StoreError::InvalidInput("Worker missing before commit".into()))?; if worker.updated_at!=plan.worker_revision{return Err(StoreError::InvalidInput(format!("stale:{}:Worker revision changed",plan.plan_id)));} if worker.retention_state=="pinned" { return Err(StoreError::InvalidInput(format!("stale:{}:hold added",plan.plan_id))); } - let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?; - if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:current assignment added",plan.plan_id))); } + let assigned:bool=tx.query_row("SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments c JOIN ticket_worker_assignments a ON a.workspace_id=c.workspace_id AND a.ticket_id=c.ticket_id AND a.assignment_id=c.assignment_id WHERE a.workspace_id=?1 AND a.runtime_id=?2 AND a.worker_id=?3)",params![workspace_id,plan.worker.runtime_id,plan.worker.worker_id],|row|row.get(0))?; + if assigned { return Err(StoreError::InvalidInput(format!("stale:{}:unfinished work added",plan.plan_id))); } let now=Utc::now().to_rfc3339(); if let Some(a)=&result.archive{ if plan.archive_id.as_deref()!=Some(&a.archive_id)||a.workspace_id!=workspace_id||a.source_runtime_id!=plan.worker.runtime_id||a.source_worker_id.to_string()!=plan.worker.worker_id||a.policy_id!=plan.policy_id||a.policy_revision!=plan.policy_revision{return Err(StoreError::InvalidInput("archive manifest mismatch".into()));} @@ -1427,29 +1427,60 @@ mod tests { ).map_err(StoreError::from)).unwrap(); assert_eq!(historical, 1); } + fn install_current_assignment( + s: &SqliteWorkspaceStore, + assignment_id: &str, + ) -> crate::Result<()> { + let assignment = TicketCoderAssignmentRecord { + workspace_id: "w".into(), + ticket_id: "ticket".into(), + assignment_id: assignment_id.into(), + worker: req().worker, + assigned_by: "test".into(), + assigned_at: "t".into(), + }; + s.set_current_ticket_coder_assignment( + &assignment, + None, + &format!("event-{assignment_id}"), + &format!("operation-{assignment_id}"), + false, + ) + .map(|_| ()) + } + + fn seed_current_assignment(s: &SqliteWorkspaceStore) { + install_current_assignment(s, "assignment").unwrap(); + } + + fn reopened_terminal_assignment_store(terminal_state: &str) -> SqliteWorkspaceStore { + let store = setup(); + seed_current_assignment(&store); + store + .with_conn(|conn| { + conn.execute( + "UPDATE typed_tickets SET workflow_state=?1 + WHERE workspace_id='w' AND ticket_id='ticket'", + [terminal_state], + )?; + conn.execute( + "UPDATE typed_tickets SET workflow_state='planning' + WHERE workspace_id='w' AND ticket_id='ticket'", + [], + )?; + Ok(()) + }) + .unwrap(); + store + } + #[test] fn assignment_and_orphan_are_authoritative() { let s = setup(); - s.with_conn(|c| { - let stable_worker_id = worker_id().to_string(); - c.execute( - "INSERT INTO ticket_worker_assignments(\ - workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at\ - ) VALUES('w','ticket','assignment','r',?1,'test','t')", - [&stable_worker_id], - )?; - c.execute( - "INSERT INTO ticket_current_worker_assignments(\ - workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at\ - ) VALUES('w','ticket','assignment','r',?1,'t')", - [&stable_worker_id], - )?; - Ok(()) - }) - .unwrap(); + seed_current_assignment(&s); let p = s.plan_worker_removal(&req(), &inv()).unwrap(); assert!( - matches!(&p.blockers[..],[WorkerRemovalBlocker::CurrentAssignment{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket") + matches!(&p.blockers[..],[WorkerRemovalBlocker::UnfinishedWork{assignment_id,ticket_id}] if assignment_id=="assignment"&&ticket_id=="ticket") ); let runtime_only = WorkerRetentionInventory { workspace_id: "w".into(), @@ -1501,6 +1532,355 @@ mod tests { .unwrap(); assert_eq!(unchanged, 2); } + #[test] + fn unfinished_work_blocker_uses_explicit_wire_kind() { + let wire = serde_json::json!({ + "kind": "unfinished_work", + "assignment_id": "assignment", + "ticket_id": "ticket", + }); + let blocker: WorkerRemovalBlocker = serde_json::from_value(wire.clone()).unwrap(); + assert_eq!(serde_json::to_value(blocker).unwrap(), wire); + } + + #[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 + 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' + WHERE workspace_id='w' AND ticket_id='ticket'", + [], + )?; + } + Ok(()) + }) + .unwrap(); + store + .update_worker_retention_policy( + "w", + 1, + &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(), + expected_worker_revision: plan.worker_revision.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 + WHERE workspace_id='w' AND assignment_id='assignment'", + [], + |row| row.get(0), + )?, + )) + }) + .unwrap(); + assert_eq!( + (retained, historical), + (1, 1), + "assignment history must survive removal" + ); + } + } + } + + #[test] + fn unfinished_work_added_after_planning_blocks_preparation() { + let store = setup(); + let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); + seed_current_assignment(&store); + 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() + ); + } + + #[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, .. } + if reason == "unfinished work added") + ); + 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 plan = store.plan_worker_removal(&req(), &inv()).unwrap(); + store + .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) + .unwrap(); + let error = install_current_assignment(&store, "new-assignment").unwrap_err(); + assert!( + matches!(error, StoreError::TicketAssignmentConflict(message) + if message.contains("being retained or has been removed")) + ); + let (current, history): (i64, i64) = store + .with_conn(|conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM ticket_current_worker_assignments + WHERE workspace_id='w' AND ticket_id='ticket'", + [], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignments + WHERE workspace_id='w' AND assignment_id='new-assignment'", + [], + |row| row.get(0), + )?, + )) + }) + .unwrap(); + assert_eq!(current, 1, "the ended responsibility must remain retained"); + assert_eq!(history, 0, "the rejected new identity must roll back"); + } + + #[test] + fn commit_rechecks_distinct_assignment_independently_of_admission_fence() { + let store = reopened_terminal_assignment_store("done"); + let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); + store + .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) + .unwrap(); + // Fault injection is confined to this in-memory authority fixture: use + // direct SQLite writes to bypass the public API removal fence, and drop + // both principal admission triggers to prove commit's independent guard. + store + .with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute_batch( + "DROP TRIGGER ticket_active_worker_principal_insert; + DROP TRIGGER ticket_active_worker_principal_update;", + )?; + tx.execute( + "INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, + assigned_by, assigned_at + ) VALUES ('w', 'ticket', 'new-assignment', 'r', ?1, 'test', 'new')", + [worker_id().to_string()], + )?; + let replaced = tx.execute( + "UPDATE ticket_current_worker_assignments + SET assignment_id='new-assignment', updated_at='new' + WHERE workspace_id='w' AND ticket_id='ticket' AND role='coder' + AND assignment_id='assignment' + AND EXISTS ( + SELECT 1 FROM ticket_assignment_work_releases + WHERE workspace_id='w' AND assignment_id='assignment' + )", + [], + )?; + if replaced != 1 { + return Err(StoreError::InvalidInput( + "fault injection requires one ended current assignment".into(), + )); + } + tx.commit()?; + Ok(()) + }) + .unwrap(); + let result = WorkerRetentionExecutionResult { + operation_id: plan.operation_id.clone(), + input_fingerprint: plan.input_fingerprint.clone(), + expected_worker_revision: plan.worker_revision.clone(), + worker_id: worker_id(), + session_disposition: plan.session_disposition, + diagnostics_disposition: plan.diagnostics_disposition, + archive: None, + source_removed: true, + diagnostics_retained: false, + }; + let error = store + .commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result) + .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.worker_tombstone("w", &plan.worker).unwrap().is_none()); + let (state, active): (String, String) = store + .with_conn(|conn| { + Ok(( + conn.query_row( + "SELECT state FROM worker_removal_operations WHERE operation_id=?1", + [&plan.operation_id], + |row| row.get(0), + )?, + conn.query_row( + "SELECT assignment_id FROM ticket_active_worker_assignments + WHERE workspace_id='w' AND ticket_id='ticket'", + [], + |row| row.get(0), + )?, + )) + }) + .unwrap(); + assert_eq!( + state, "executing", + "rejected commit must remain recoverable" + ); + assert_eq!( + active, "new-assignment", + "the ended identity must not reactivate" + ); + } + + #[test] + fn terminal_assignment_does_not_bypass_hold_added_before_commit() { + let store = setup(); + seed_current_assignment(&store); + store + .with_conn(|conn| { + conn.execute( + "UPDATE typed_tickets SET workflow_state='done' + WHERE workspace_id='w' AND ticket_id='ticket'", + [], + )?; + Ok(()) + }) + .unwrap(); + let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); + store + .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) + .unwrap(); + store + .with_conn(|conn| { + conn.execute( + "UPDATE worker_registry SET retention_state='pinned' + WHERE workspace_id='w' AND runtime_id='r' AND worker_id=?1", + [worker_id().to_string()], + )?; + Ok(()) + }) + .unwrap(); + let result = WorkerRetentionExecutionResult { + operation_id: plan.operation_id.clone(), + input_fingerprint: plan.input_fingerprint.clone(), + expected_worker_revision: plan.worker_revision.clone(), + worker_id: worker_id(), + session_disposition: plan.session_disposition, + diagnostics_disposition: plan.diagnostics_disposition, + archive: None, + source_removed: true, + diagnostics_retained: false, + }; + let error = store + .commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result) + .unwrap_err(); + assert!( + matches!(error, WorkerRetentionError::StalePlan { reason, .. } + if reason == "hold added") + ); + assert!( + store + .get_worker_registry("w", &plan.worker) + .unwrap() + .is_some() + ); + } + #[test] fn concurrent_plan_converges_and_purge_omits_tombstone() { let s = std::sync::Arc::new(setup()); diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 2ac1ef033..3bf74e6f2 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -355,14 +355,16 @@ impl ServerConfig { workspace: &WorkspaceRecord, repositories: Vec, ) -> Result { - if repositories.is_empty() { - return Err(Error::Config(format!( - "Workspace {} has no registered repository", - workspace.workspace_id - ))); - } - let workspace_execution_root = - Self::default_workspace_backend_data_root(&workspace.workspace_id); + // Derive scoped storage from the configured authority database, not process-global + // defaults. This also keeps custom server roots and temporary API fixtures isolated. + let workspace_execution_root = self + .database_path + .parent() + .ok_or_else(|| { + Error::Config("Server database path must have a parent directory".to_string()) + })? + .join("workspaces") + .join(&workspace.workspace_id); let repositories = repositories .into_iter() .map(|repository| ConfiguredRepository { @@ -386,9 +388,8 @@ impl ServerConfig { scoped .workspace_created_at .clone_from(&workspace.created_at); + scoped.embedded_runtime_store_root = workspace_execution_root.join("embedded-runtime"); scoped.workspace_execution_root = workspace_execution_root; - scoped.embedded_runtime_store_root = - Self::default_embedded_runtime_store_root(&workspace.workspace_id); scoped.repositories = repositories; // Runtime trust is server-global. Only explicitly assigned sources enter // this Workspace's registry and receive Workspace-scoped capabilities. @@ -2549,7 +2550,7 @@ fn workspace_create_response( ) -> WorkspaceCreateResponse { WorkspaceCreateResponse { workspace: workspace_summary(created.workspace), - repository: workspace_repository_record(created.repository), + repository: created.repository.map(workspace_repository_record), config_revision: created.config_revision, request_fingerprint: created.request_fingerprint, replayed: created.replayed, @@ -3471,7 +3472,7 @@ impl WorkspaceApi { .map_err(|error| { Error::Store(format!("failed to register subjektiv storage: {error}")) })?; - let api = Self { + let mut api = Self { config_store, repository_secrets, signing_identities, @@ -3483,11 +3484,7 @@ impl WorkspaceApi { authority: SqliteWorkspaceAuthority::new( config.database_path.clone(), config.workspace_id.clone(), - )? - .with_merge_revision_source(Arc::new(MergeRequestRepositorySource { - workspace_id: config.workspace_id.clone(), - reader: RepositoryRegistryReader::new(config.repositories.clone()), - })), + )?, _worker_projection_shutdown: Arc::new(WorkerProjectionShutdownGuard { service: Arc::downgrade(&worker_projection), }), @@ -3513,6 +3510,14 @@ impl WorkspaceApi { worker_control_locks: Arc::new(Mutex::new(HashMap::new())), attachment_upload_grants: Arc::new(Mutex::new(HashMap::new())), }; + // The observer snapshot retains the unconfigured authority, not itself: + // source observation uses Repository access + Runtime authority without a cycle. + let observer_api = api.clone(); + api.authority = + api.authority + .with_merge_revision_source(Arc::new(RuntimeTicketMergeRevisionSource { + api: observer_api, + })); if let Some(dispatcher) = worker_remove_dispatcher { dispatcher .install_executor(Arc::new( @@ -5697,7 +5702,7 @@ fn ticket_target_capabilities_for_workdir( } let Some(assignment) = api .store - .get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)? + .get_active_ticket_role_assignment_for_worker(&api.config.workspace_id, worker)? .filter(|assignment| assignment.role == TicketAssignmentRole::Coder) else { return Ok(source_capabilities); @@ -6437,6 +6442,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .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(), )) @@ -10225,8 +10233,7 @@ impl server_api::ServerApi for ServerApiContractService { workspace_id: String, id: String, request: server_api::CompleteTicketRequest, - ) -> std::result::Result - { + ) -> std::result::Result { let Json(response) = scoped_complete_ticket( State(self.workspace_api()?.clone()), contract_request_headers(&context)?, @@ -10235,7 +10242,25 @@ impl server_api::ServerApi for ServerApiContractService { ) .await .map_err(ApiError::into_repository_api_error)?; - Ok(response) + 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( @@ -10451,12 +10476,14 @@ impl server_api::ServerApi for ServerApiContractService { 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), ) @@ -10467,6 +10494,7 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_ready( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, request: server_api::TicketMarkReadyRequest, @@ -10478,6 +10506,7 @@ impl server_api::ServerApi for ServerApiContractService { }; let Json(response) = scoped_mark_ticket_ready_from_browser( State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, AxumPath(ScopedRecordPath { workspace_id, id }), Json(request), ) @@ -10504,12 +10533,14 @@ impl server_api::ServerApi for ServerApiContractService { async fn ticket_queue( &self, + context: server_api::ServerRequestContext, workspace_id: String, id: String, request: server_api::BrowserQueueTicketRequest, ) -> std::result::Result { let Json(response) = scoped_queue_ticket( State(self.workspace_api()?.clone()), + contract_request_headers(&context)?, AxumPath(ScopedRecordPath { workspace_id, id }), Json(request), ) @@ -10520,12 +10551,14 @@ impl server_api::ServerApi for ServerApiContractService { 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), ) @@ -13185,7 +13218,7 @@ async fn scoped_set_ticket_assignment( } if api .store - .get_current_ticket_role_assignment( + .get_active_ticket_role_assignment( &workspace_id, &ticket.id, TicketAssignmentRole::Coder, @@ -13318,7 +13351,7 @@ async fn scoped_cancel_ticket_implementation( .into()); } - let current = api.store.get_current_ticket_role_assignment( + let current = api.store.get_active_ticket_role_assignment( &path.workspace_id, &ticket.id, TicketAssignmentRole::Coder, @@ -13487,7 +13520,7 @@ fn validate_ticket_assignment_spawn( if let Some(current) = api .store - .get_current_ticket_coder_assignment(&api.config.workspace_id, &assignment.ticket_id)? + .get_active_ticket_worker_assignment(&api.config.workspace_id, &assignment.ticket_id)? { let replay_matches = api .store @@ -13571,7 +13604,7 @@ fn assign_ticket_worker_from_lifecycle( if operation.action == "assign" && operation.ticket_id == assignment.ticket_id && operation.worker.as_ref() == Some(&worker) - && let Some(current) = api.store.get_current_ticket_coder_assignment( + && let Some(current) = api.store.get_active_ticket_worker_assignment( &api.config.workspace_id, &assignment.ticket_id, )? @@ -13711,12 +13744,10 @@ impl ticket::TicketTargetAuthority for WorkspaceTicketTargetAuthority { .map(str::trim) .filter(|value| !value.is_empty()) .or(repository.default_ref.as_deref()) - .ok_or_else(|| { - ticket::TicketError::MissingTargetSelector(repository.repository_key.clone()) - })?; + .map(str::to_owned); Ok(ticket::ResolvedTicketTarget { repository_key: repository.repository_key, - ref_selector: selector.to_owned(), + ref_selector: selector, access: TicketTargetAccess::ReadOnly, }) } @@ -13791,47 +13822,46 @@ async fn scoped_edit_ticket_item( browser_ticket_detail(&api, &path.id) } -async fn scoped_transition_ticket_state( - State(api): State, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = match request.state { +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, }; - if state == TicketWorkflowState::Done { - return Err(Error::TicketAssignmentConflict( - "done is guarded by CompleteMergeRequest with an approved exact source ref and operation_id".to_string(), - ).into()); - } - let current = api.authority.ticket(&path.id)?; - if state == TicketWorkflowState::InProgress - && current.state != TicketWorkflowState::InProgress.as_str() - { - return Err(Error::TicketAssignmentConflict( - "generic Ticket state mutation cannot enter inprogress; use Queue acceptance or atomic ready-state Coder assignment" - .to_string(), - ) - .into()); - } - let mut change = TicketStateChange::new( - current.state, - state.as_str(), - request - .reason - .unwrap_or_else(|| "state changed from Web Ticket API".to_owned()), - request.body.unwrap_or_default(), - ); - change.author = request.author; - browser_ticket_backend(&api)? - .set_workflow_state(TicketIdOrSlug::Id(path.id.clone()), change) - .map_err(Error::from)?; + 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_item_revision: request.expected_item_revision, + 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) } @@ -13859,26 +13889,24 @@ async fn scoped_append_ticket_event( async fn scoped_mark_ticket_ready_from_browser( State(api): State, + headers: HeaderMap, AxumPath(path): AxumPath, Json(request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &path.workspace_id)?; - browser_ticket_backend(&api)? - .mark_ready( - TicketIdOrSlug::Id(path.id.clone()), - ticket::TicketMarkReady { - operation_key: request.operation_key, - reason: request.reason, - author: Some("web".to_owned()), - intake_summary: None, - }, - ) - .map_err(Error::from)?; + let _ = scoped_mark_ticket_ready( + State(api.clone()), + AxumPath((path.workspace_id.clone(), path.id.clone())), + headers, + Json(request), + ) + .await?; browser_ticket_detail(&api, &path.id) } async fn scoped_queue_ticket( State(api): State, + headers: HeaderMap, AxumPath(path): AxumPath, Json(_request): Json, ) -> ApiResult> { @@ -13886,7 +13914,7 @@ async fn scoped_queue_ticket( let result = execute_ticket_rest_operation( &api, &path.workspace_id, - HeaderMap::new(), + headers, TicketBackendOperation::QueueReady { id: TicketIdOrSlug::Id(path.id.clone()), queued_by: "workspace-web".to_string(), @@ -13901,16 +13929,26 @@ 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)?; - browser_ticket_backend(&api)? - .close( - TicketIdOrSlug::Id(path.id.clone()), - MarkdownText::new(request.resolution), - ) - .map_err(Error::from)?; + 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_item_revision: request.expected_item_revision, + 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) } @@ -13923,28 +13961,6 @@ fn generic_ticket_state_change(operation: &TicketBackendOperation) -> Option<&Ti } } -fn reject_unguarded_ticket_start(operation: &TicketBackendOperation) -> Result<()> { - if generic_ticket_state_change(operation) - .is_some_and(|change| change.to == "inprogress" && change.from != "inprogress") - { - return Err(Error::TicketAssignmentConflict( - "inprogress is guarded by Queue acceptance or atomic ready-state Coder assignment" - .to_string(), - )); - } - Ok(()) -} - -fn reject_unguarded_ticket_completion(operation: &TicketBackendOperation) -> Result<()> { - reject_unguarded_ticket_start(operation)?; - if generic_ticket_state_change(operation).is_some_and(|change| change.to == "done") { - return Err(Error::TicketAssignmentConflict( - "done is guarded by CompleteMergeRequest with an approved exact source ref and operation_id".to_string(), - )); - } - Ok(()) -} - fn validate_ticket_target_repository_keys( api: &WorkspaceApi, workspace_id: &str, @@ -14110,9 +14126,26 @@ async fn execute_ticket_rest_operation( // calls carry source headers; when either header is present, the complete pair is required // and authenticated before source attribution is attached. let source = optional_worker_mutation_source(api, workspace_id, &headers)?; - reject_unguarded_ticket_completion(&operation)?; validate_ticket_repository_operation(api, &operation)?; let before = target.as_ref().and_then(|id| backend.show(id.clone()).ok()); + if let Some(source) = source.as_ref() + && (generic_ticket_state_change(&operation).is_some() + || matches!( + operation, + TicketBackendOperation::Close { .. } + | TicketBackendOperation::QueueReady { .. } + | TicketBackendOperation::MarkReady { .. } + )) + { + let context = worker_ticket_source_context(api, workspace_id, source, before.as_ref()); + let is_orchestrator = + find_workspace_orchestrator(api).is_some_and(|worker| worker.worker == *source); + if context.actor_role != "coder" && !is_orchestrator { + return Err(Error::WorkspacePermissionDenied( + "Ticket state decisions require the assigned Worker or registered Workspace Orchestrator".into(), + ).into()); + } + } let mut ticket_item_check = source .as_ref() .cloned() @@ -14129,41 +14162,11 @@ async fn execute_ticket_rest_operation( .to_string(), ) })?; - let dependency_check = backend - .dependency_check(TicketIdOrSlug::Id(ticket.meta.id.clone())) - .map_err(Error::from)?; - if !dependency_check.queue_guard.can_queue_for_orchestrator { - let reason = dependency_check - .queue_guard - .blocked_reason - .or(dependency_check.queue_guard.reason) - .unwrap_or_else(|| "Queue dependency validation failed".to_string()); - return Err(Error::TicketAssignmentConflict(reason).into()); - } - let candidates = dependency_check.queue_tickets; let mut assignment_ids = BTreeMap::new(); - for ticket_id in candidates { - if api - .store - .get_current_ticket_role_assignment( - workspace_id, - &ticket_id, - TicketAssignmentRole::Coder, - )? - .is_some() - { - return Err(Error::TicketAssignmentConflict(format!( - "Queue rejects Ticket {ticket_id} while a Coder assignment is active" - )) - .into()); - } - let assignment = active_orchestrator_assignment(api, workspace_id, &ticket_id)? - .ok_or_else(|| { - Error::TicketAssignmentConflict(format!( - "Queue requires role=orchestrator assignment for Ticket {ticket_id}" - )) - })?; - assignment_ids.insert(ticket_id, assignment.assignment_id); + if let Some(assignment) = + active_orchestrator_assignment(api, workspace_id, &ticket.meta.id)? + { + assignment_ids.insert(ticket.meta.id.clone(), assignment.assignment_id); } let assignment_json = serde_json::to_string(&assignment_ids).map_err(|error| { Error::Config(format!("failed to encode Queue assignment fence: {error}")) @@ -14177,11 +14180,10 @@ async fn execute_ticket_rest_operation( .iter() .map(|byte| format!("{byte:02x}")) .collect::(); + if !assignment_ids.is_empty() { + event_attributes.insert("queue_orchestrator_assignments".into(), assignment_json); + } event_attributes.extend([ - ( - "queue_orchestrator_assignments".to_string(), - assignment_json, - ), ( "routing_principal".to_string(), "workspace-orchestrator".to_string(), @@ -14200,6 +14202,55 @@ 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_item_revision: ticket::ticket_item_revision(before), + expected_state, + state, + reason, + author, + references, + }, + ) + .map_err(Error::from)?; + return Ok(TicketBackendOperationResult::Unit); + } + } + let (result, created_ticket) = match operation { TicketBackendOperation::Create { input } => { let (reference, ticket) = backend.create_with_snapshot(input).map_err(Error::from)?; @@ -14391,15 +14442,6 @@ async fn scoped_create_ticket_record( headers: HeaderMap, Json(input): Json, ) -> ApiResult> { - if input - .workflow_state - .is_some_and(|state| state != TicketWorkflowState::Planning) - { - return Err(settings_bad_request( - "ticket_create_state_bypass", - "Ticket creation must start in planning; use guarded workflow operations for later states", - )); - } let result = execute_ticket_rest_operation( &api, &path.workspace_id, @@ -14478,19 +14520,16 @@ async fn scoped_add_ticket_state_change( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, headers: HeaderMap, - Json(change): Json, + Json(request): Json, ) -> ApiResult { - let result = execute_ticket_rest_operation( - &api, - &workspace_id, + let _ = scoped_ticket_state_update( + State(api), headers, - TicketBackendOperation::AddStateChanged { - id: TicketIdOrSlug::Query(id), - change, - }, + AxumPath((workspace_id, id)), + Json(request), ) .await?; - ticket_rest_unit(result) + Ok(StatusCode::NO_CONTENT) } async fn scoped_add_ticket_intake_summary( @@ -14525,39 +14564,35 @@ async fn scoped_set_ticket_state_field( State(api): State, AxumPath((workspace_id, id, field)): AxumPath<(String, String, String)>, headers: HeaderMap, - Json(change): Json, + Json(request): Json, ) -> ApiResult { - let result = execute_ticket_rest_operation( - &api, - &workspace_id, + if field != "state" { + return Err(Error::InvalidInput("unsupported Ticket state field".into()).into()); + } + let _ = scoped_ticket_state_update( + State(api), headers, - TicketBackendOperation::SetStateField { - id: TicketIdOrSlug::Query(id), - field, - change, - }, + AxumPath((workspace_id, id)), + Json(request), ) .await?; - ticket_rest_unit(result) + Ok(StatusCode::NO_CONTENT) } async fn scoped_set_ticket_workflow_state( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, headers: HeaderMap, - Json(change): Json, + Json(request): Json, ) -> ApiResult { - let result = execute_ticket_rest_operation( - &api, - &workspace_id, + let _ = scoped_ticket_state_update( + State(api), headers, - TicketBackendOperation::SetWorkflowState { - id: TicketIdOrSlug::Query(id), - change, - }, + AxumPath((workspace_id, id)), + Json(request), ) .await?; - ticket_rest_unit(result) + Ok(StatusCode::NO_CONTENT) } async fn scoped_mark_ticket_ready( @@ -14634,7 +14669,7 @@ impl merge_request::AssignmentSource for MergeRequestAssignmentSource { ticket_id: &str, ) -> std::result::Result, String> { self.store - .get_current_ticket_coder_assignment(workspace_id, ticket_id) + .get_active_ticket_worker_assignment(workspace_id, ticket_id) .map(|value| { value.map(|assignment| merge_request::CurrentAssignment { assignment_id: assignment.assignment_id, @@ -14653,12 +14688,36 @@ struct MergeRequestRepositorySource { reader: RepositoryRegistryReader, } -impl TicketMergeRevisionSource for MergeRequestRepositorySource { - fn resolve_subject_ref(&self, repository_id: &str, selector: &str) -> Option { - self.reader - .observe_merge_target(repository_id, Some(selector)) - .ok() - .map(|target| target.commit) +#[derive(Clone)] +struct RuntimeTicketMergeRevisionSource { + api: WorkspaceApi, +} + +impl TicketMergeRevisionSource for RuntimeTicketMergeRevisionSource { + fn resolve_subject_ref( + &self, + ticket_id: &str, + repository_id: &str, + selector: &str, + ) -> std::result::Result { + let assignment = self + .api + .store + .get_active_ticket_worker_assignment(self.api.workspace_id(), ticket_id) + .map_err(|error| merge_ref_diagnostic(error.into()))? + .ok_or_else(|| MergeRequestRefDiagnostic { + code: "source_ref_runtime_unavailable".into(), + message: "No current Coder Runtime is available to observe the source ref".into(), + })?; + observe_published_source_ref( + &self.api, + self.api.workspace_id(), + &assignment.worker.runtime_id, + repository_id, + selector, + ) + .map(|observation| observation.revision_ref) + .map_err(merge_ref_diagnostic) } } @@ -15142,20 +15201,6 @@ fn public_merge_event(event: merge_request::MergeEvent) -> server_api::MergeEven } } -fn public_ticket_completion_event( - event: merge_request::TicketCompletionEvent, -) -> server_api::TicketCompletionEvent { - server_api::TicketCompletionEvent { - operation_id: event.operation_id, - ticket_id: event.ticket_id, - item_revision: event.item_revision, - merge_request_ids: event.merge_request_ids, - requirement_approval_event_id: event.requirement_approval_event_id, - completed_by: public_merge_request_worker_identity(event.completed_by), - created_at: event.created_at.to_rfc3339(), - } -} - fn public_merge_strategy(strategy: merge_request::MergeStrategy) -> server_api::MergeStrategy { match strategy { merge_request::MergeStrategy::FastForward => server_api::MergeStrategy::FastForward, @@ -15336,7 +15381,7 @@ async fn scoped_list_merge_requests( ) { (Some(selector), Some(ticket_id)) => match api .store - .get_current_ticket_coder_assignment(&workspace_id, ticket_id)? + .get_active_ticket_worker_assignment(&workspace_id, ticket_id)? { Some(assignment) => match observe_published_source_ref( &api, @@ -15374,7 +15419,11 @@ async fn scoped_list_merge_requests( let ticket_ids = merge_request.ticket_ids.clone(); let thread_event_count = merge_request.thread.len(); Ok(MergeRequestListItem { - summary: merge_request_summary(merge_request, repository_key, current_subject_ref), + summary: merge_request_summary(merge_request, repository_key, current_subject_ref.ok_or_else(|| { + ref_diagnostics.first().cloned().unwrap_or_else(|| MergeRequestRefDiagnostic { + code: "source_ref_unavailable".into(), message: "Source observation is unavailable".into(), + }) + })), ticket_ids, thread_event_count, ref_diagnostics, @@ -15397,7 +15446,10 @@ fn merge_ref_diagnostic(error: ApiError) -> MergeRequestRefDiagnostic { message: diagnostic.message, }) .unwrap_or_else(|| MergeRequestRefDiagnostic { - code: "merge_ref_observation_unavailable".to_string(), + code: match &error { + Error::RuntimeOperationFailed { code, .. } => code.clone(), + _ => "merge_ref_observation_unavailable".to_string(), + }, message: sanitize_backend_error(&error.to_string()), }) } @@ -15438,6 +15490,50 @@ fn merge_ref_response( } } +// Persisted results are independent of provider availability and mutable selectors. +fn integrated_merge_refs( + mr: &merge_request::MergeRequest, +) -> Option<( + server_api::MergeRequestRefResponse, + server_api::MergeRequestRefResponse, +)> { + if mr.state != merge_request::MergeRequestState::Merged { + return None; + } + let result = mr.merged_result().ok(); + let diagnostic = mr + .integration_approval() + .err() + .map(|error| MergeRequestRefDiagnostic { + code: error.code().into(), + message: "Stored integration evidence is missing, inconsistent, or revoked".into(), + }); + let source = server_api::MergeRequestRefResponse { + status: if diagnostic.is_none() { + "known" + } else { + "unknown" + } + .into(), + revision_ref: result.map(|merge| merge.approved_source_ref.clone()), + observed_at: result.map_or_else( + || mr.updated_at.to_rfc3339(), + |merge| merge.created_at.to_rfc3339(), + ), + diagnostic, + }; + let target = server_api::MergeRequestRefResponse { + status: if result.is_some() { "known" } else { "unknown" }.into(), + revision_ref: result.map(|merge| merge.target_ref_after.clone()), + observed_at: source.observed_at.clone(), + diagnostic: result.is_none().then(|| MergeRequestRefDiagnostic { + code: "merge_result_missing".into(), + message: "Merged request has no stored result".into(), + }), + }; + Some((source, target)) +} + async fn scoped_show_merge_request( State(api): State, AxumPath((workspace_id, merge_request_id)): AxumPath<(String, String)>, @@ -15447,6 +15543,7 @@ async fn scoped_show_merge_request( 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, @@ -15460,46 +15557,51 @@ async fn scoped_show_merge_request( })?; let assignment = api .store - .get_current_ticket_coder_assignment(&workspace_id, ticket_id)?; - let source = match (mr.selector_from.as_deref(), assignment.as_ref()) { - (Some(selector), Some(assignment)) => { - match observe_published_source_ref( + .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(), + revision_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, - selector, + &mr.selector_to, ) { 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(), - revision_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", - ), + }, + 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 @@ -15534,25 +15636,35 @@ async fn scoped_merge_request_readiness( let ticket_id = merge_request_ticket_id(&mr)?; let assignment = api .store - .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)?; - let (current_subject_ref, source_blocker) = 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.revision_ref), None), - Err(error) => { - let diagnostic = merge_ref_diagnostic(error); - let blocker = source_ref_readiness_blocker(&diagnostic.code); - (None, Some(blocker.to_string())) + .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.revision_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), } - }, - (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, @@ -15594,9 +15706,9 @@ async fn scoped_open_merge_request( let source = authenticate_worker_mutation_source(&api, &workspace_id, &headers)?; let assignment = api .store - .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) + 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 @@ -15693,9 +15805,9 @@ async fn scoped_repair_merge_request_selector( let ticket_id = merge_request_ticket_id(&mr)?; let assignment = api .store - .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) + Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into()) })?; let resolved_subject_ref = observe_published_source_ref( &api, @@ -15765,9 +15877,9 @@ async fn scoped_register_merge_request_review_capability( } let assignment = api .store - .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) + 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 @@ -15875,9 +15987,9 @@ async fn scoped_submit_merge_request_review( let mr = store.get_by_id(&workspace_id, &merge_request_id)?; let assignment = api .store - .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) + 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)?, @@ -15937,9 +16049,9 @@ async fn scoped_revoke_merge_request_review( let ticket_id = merge_request_ticket_id(&mr)?; let assignment = api .store - .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) + 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 @@ -16007,9 +16119,9 @@ async fn scoped_complete_merge_request( } let assignment = api .store - .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? + .get_active_ticket_worker_assignment(&workspace_id, &ticket_id)? .ok_or_else(|| { - Error::TicketAssignmentConflict("Ticket has no current assigned Coder".into()) + Error::TicketAssignmentConflict("Ticket has no active Coder work assignment".into()) })?; let selector = mr .selector_from @@ -16067,35 +16179,74 @@ async fn scoped_complete_ticket( headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, Json(input): Json, -) -> ApiResult> { - let workspace_id = parse_workspace_id(&workspace_id)?; +) -> ApiResult> { + scoped_ticket_state_update( + State(api), + headers, + AxumPath((workspace_id, ticket_id)), + Json(ticket::TicketStateUpdate { + operation_key: input.operation_key, + expected_item_revision: input.expected_item_revision, + 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, +) -> ApiResult> { + validate_workspace_scope(&api, &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)?; - require_online_workspace_orchestrator_source(&api, &source)?; - let assignment_id = api - .store - .get_current_ticket_coder_assignment(&workspace_id, &ticket_id)? - .map(|assignment| assignment.assignment_id) - .unwrap_or_default(); - let event = merge_request_store(&api, &workspace_id)?.complete_ticket( - merge_request::CompleteTicket { - ticket_id, - operation_id: input.operation_id, - item_revision: input.item_revision, - merge_request_ids: input.merge_request_ids, - requirement_approval_event_id: input.requirement_approval_event_id, - auth: merge_request::MergeRequestAuth { - workspace_id, - repository_id: String::new(), - runtime_id: source.runtime_id, - worker_id: source.worker_id, - assignment_id, - }, - now: Utc::now(), - }, - )?; - Ok(Json(public_ticket_completion_event(event))) + let mut backend = browser_ticket_backend(&api)?; + let before = backend + .show(TicketIdOrSlug::Id(ticket_id.clone())) + .map_err(Error::from)?; + if let Some(source) = optional_worker_mutation_source(&api, &workspace_id, &headers)? { + 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()) + && event.attributes.get("operation_key") == Some(&request.operation_key) + && event.attributes.get("source_runtime_id") == Some(&source.runtime_id) + && event.attributes.get("source_worker_id") == Some(&source.worker_id) + }); + let replay = replay_event.is_some(); + let context = worker_ticket_source_context(&api, &workspace_id, &source, Some(&before)); + let is_orchestrator = + find_workspace_orchestrator(&api).is_some_and(|worker| worker.worker == source); + if !replay && context.actor_role != "coder" && !is_orchestrator { + return Err(Error::WorkspacePermissionDenied( + "Ticket state decisions require the assigned Worker or registered Workspace Orchestrator".into(), + ).into()); + } + request.author = Some(author); + let attributes = if let Some(event) = replay_event { + // Replay authenticates the same actor, but retains the receipt's + // original work identity. Completion/reopen never revives that grant. + event + .attributes + .iter() + .filter(|(key, _)| key.starts_with("source_")) + .map(|(key, value)| (key.clone(), value.clone())) + .collect() + } else { + context.attributes("state_update") + }; + backend = backend.with_event_attributes(attributes); + } else { + request.author = Some("workspace-user".into()); + } + let ticket = backend + .update_state(&ticket_id, request) + .map_err(Error::from)?; + Ok(Json(ticket)) } fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { @@ -16109,19 +16260,24 @@ async fn scoped_close_ticket_record( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, headers: HeaderMap, - Json(resolution): Json, + Json(request): Json, ) -> ApiResult { - let result = execute_ticket_rest_operation( - &api, - &workspace_id, + let _ = scoped_ticket_state_update( + State(api), headers, - TicketBackendOperation::Close { - id: TicketIdOrSlug::Query(id), - resolution, - }, + AxumPath((workspace_id, id)), + Json(ticket::TicketStateUpdate { + state: TicketWorkflowState::Closed, + operation_key: request.operation_key, + expected_item_revision: request.expected_item_revision, + expected_state: request.expected_state, + reason: request.reason, + references: request.references, + author: request.author, + }), ) .await?; - ticket_rest_unit(result) + Ok(StatusCode::NO_CONTENT) } async fn scoped_record_ticket_relation( @@ -16417,7 +16573,7 @@ fn active_orchestrator_assignment( workspace_id: &str, ticket_id: &str, ) -> Result> { - let assignment = api.store.get_current_ticket_role_assignment( + let assignment = api.store.get_active_ticket_role_assignment( workspace_id, ticket_id, TicketAssignmentRole::Orchestrator, @@ -16460,7 +16616,7 @@ fn worker_ticket_source_context( ) -> WorkerTicketSourceContext { let assignment = ticket.and_then(|ticket| { api.store - .get_current_ticket_coder_assignment(workspace_id, &ticket.meta.id) + .get_active_ticket_worker_assignment(workspace_id, &ticket.meta.id) .ok() .flatten() }); @@ -16603,7 +16759,7 @@ fn notify_ticket_recipients( let mut recipients = Vec::new(); if let Some(assignment) = api .store - .get_current_ticket_coder_assignment(workspace_id, ticket_id) + .get_active_ticket_worker_assignment(workspace_id, ticket_id) .ok() .flatten() { @@ -21186,7 +21342,7 @@ fn worker_retention_error_response( crate::retention::WorkerRetentionError::Blocked(_) => worker_remove_error_response( StatusCode::CONFLICT, "worker_removal_blocked", - "Worker removal is blocked by current assignment, hold, or retention policy", + "Worker removal is blocked by unfinished Ticket work, hold, or retention policy", ), crate::retention::WorkerRetentionError::Invalid(_) => worker_remove_error_response( StatusCode::BAD_REQUEST, @@ -24308,15 +24464,15 @@ fn build_runtime_cleanup_plan( let links = api .store .list_worker_workdir_links(&api.config.workspace_id, &record.worker)?; - let current_assignment = api.store.get_current_ticket_role_assignment_for_worker( + let active_assignment = api.store.get_active_ticket_role_assignment_for_worker( &api.config.workspace_id, &record.worker, )?; let is_running = live_running_worker_ids.contains(&record.worker); let pinned = record.retention_state == "pinned"; - let blocking_reason = if let Some(assignment) = current_assignment { + let blocking_reason = if let Some(assignment) = active_assignment { Some(format!( - "worker has current Ticket assignment `{}` (`{}`)", + "worker has unfinished work for Ticket `{}` (`{}`)", assignment.ticket_id, assignment.role.as_str() )) @@ -24532,13 +24688,13 @@ async fn execute_runtime_cleanup_with_context( ); if let Some(assignment) = api .store - .get_current_ticket_role_assignment_for_worker(&api.config.workspace_id, &worker)? + .get_active_ticket_role_assignment_for_worker(&api.config.workspace_id, &worker)? { return Err(cleanup_api_error( runtime_id, "workspace_cleanup_worker_assigned", &format!( - "Worker is assigned to Ticket `{}` as `{}` and cannot be deleted", + "Worker has unfinished work for Ticket `{}` as `{}` and cannot be deleted", assignment.ticket_id, assignment.role.as_str() ), @@ -31845,6 +32001,7 @@ impl From for ApiError { ticket::TicketError::Locked { .. } => "ticket_locked", ticket::TicketError::Conflict(_) | ticket::TicketError::StaleWorkflowState { .. } + | ticket::TicketError::StaleItemRevision { .. } | ticket::TicketError::InvalidWorkflowTransition { .. } | ticket::TicketError::BlockingRelations(_) | ticket::TicketError::OperationFingerprintMismatch { .. } => "ticket_conflict", @@ -31961,7 +32118,10 @@ fn api_error_status(error: &Error) -> StatusCode { Error::Ticket( ticket::TicketError::Ambiguous { .. } | ticket::TicketError::Locked { .. } - | ticket::TicketError::Conflict(_), + | ticket::TicketError::Conflict(_) + | ticket::TicketError::StaleItemRevision { .. } + | ticket::TicketError::StaleWorkflowState { .. } + | ticket::TicketError::OperationFingerprintMismatch { .. }, ) => StatusCode::CONFLICT, Error::Ticket( ticket::TicketError::InvalidPathComponent(_) @@ -32097,6 +32257,7 @@ mod tests { mod auth_logging_tests; mod subject_spawn_tests; mod subjektiv_jobs_tests; + mod ticket_evidence_tests; mod value_profiles_tests; mod worker_operations_tests; include!("server_workspace_config_tests.rs"); @@ -35600,43 +35761,6 @@ mod tests { assert!(reject_non_browser_reopen_auth(&HeaderMap::new()).is_ok()); } - #[test] - fn generic_worker_state_change_cannot_enter_inprogress() { - for from in ["planning", "ready", "queued"] { - let change = || { - TicketStateChange::new( - from, - "inprogress", - "worker-tool", - "bypass assignment-aware start", - ) - }; - let operations = [ - TicketBackendOperation::SetWorkflowState { - id: TicketIdOrSlug::Query("T1".to_string()), - change: change(), - }, - TicketBackendOperation::SetStateField { - id: TicketIdOrSlug::Query("T1".to_string()), - field: "state".to_string(), - change: change(), - }, - TicketBackendOperation::AddStateChanged { - id: TicketIdOrSlug::Query("T1".to_string()), - change: change(), - }, - ]; - for operation in operations { - let error = reject_unguarded_ticket_completion(&operation).unwrap_err(); - assert!( - error - .to_string() - .contains("atomic ready-state Coder assignment") - ); - } - } - } - #[test] fn feature_completion_api_conversions_preserve_nonce_and_context_for_aba_requests() { for nonce in ["first", "second", "first"] { @@ -35664,36 +35788,6 @@ mod tests { } } - #[test] - fn flow_or_generic_worker_state_change_is_not_ticket_completion_authority() { - let change = || { - TicketStateChange::new( - "inprogress", - "done", - "flow reached terminal state", - "terminal flow state", - ) - }; - for operation in [ - TicketBackendOperation::SetWorkflowState { - id: TicketIdOrSlug::Query("T1".to_string()), - change: change(), - }, - TicketBackendOperation::SetStateField { - id: TicketIdOrSlug::Query("T1".to_string()), - field: "state".to_string(), - change: change(), - }, - TicketBackendOperation::AddStateChanged { - id: TicketIdOrSlug::Query("T1".to_string()), - change: change(), - }, - ] { - let error = reject_unguarded_ticket_completion(&operation).unwrap_err(); - assert!(error.to_string().contains("CompleteMergeRequest")); - } - } - #[test] fn failed_api_log_is_structured_and_omits_query_values() { let uri = "/api/w/workspace/tickets?access_token=secret" @@ -42692,7 +42786,10 @@ mod tests { ); assert_eq!( scoped.workspace_execution_root, - ServerConfig::default_workspace_backend_data_root("remote-workspace") + base.database_path + .parent() + .unwrap() + .join("workspaces/remote-workspace") ); } @@ -43380,6 +43477,28 @@ mod tests { .unwrap(); let current_job = ticket_check_job(&api, &ticket_ref.id); let current_attempt = wait_for_ticket_check_attempt(&api, ¤t_job).await; + // AwaitingResult can be persisted before Runtime input delivery finishes. + // Observe both dispatches before attributing subsequent inputs to a result. + tokio::time::timeout(std::time::Duration::from_secs(5), async { + loop { + let delivered = { + let inputs = execution.inputs.lock().unwrap(); + [&stale_job.request.job_id, ¤t_job.request.job_id] + .iter() + .all(|id| { + inputs + .iter() + .any(|(_, content)| content.contains(id.as_str())) + }) + }; + if delivered { + break; + } + tokio::task::yield_now().await; + } + }) + .await + .expect("both checker inputs must be delivered before testing result notifications"); execution.take_inputs(); api.accept_backend_job_result( @@ -43397,7 +43516,11 @@ mod tests { }, ) .unwrap(); - assert!(execution.take_inputs().is_empty()); + let unexpected = execution.take_inputs(); + assert!( + unexpected.is_empty(), + "unexpected inputs after stale result: {unexpected:?}" + ); let stale_delivery_id = crate::backend_job::delivery_id(&stale_job.request.job_id, &stale_attempt.attempt_id); let (stale_delivery, claimed) = api @@ -44963,6 +45086,252 @@ mod tests { assert!(auth["cookie_name"].is_string()); } + #[tokio::test] + async fn repository_free_workspace_can_be_created_selected_and_used_then_register_repository() { + let temp = tempfile::tempdir().unwrap(); + let config = test_server_config(temp.path()); + let store = Arc::new(SqliteWorkspaceStore::open(&config.database_path).unwrap()); + let owner = seed_test_api_token(store.as_ref(), "empty-owner"); + let foreign = seed_test_api_token(store.as_ref(), "empty-foreign"); + let app = build_workspace_server_router(config.clone(), store.clone()) + .await + .unwrap(); + let wire = + json!({"operation_key": "create-empty-workspace", "display_name": "Empty Workspace"}); + request_json( + app.clone(), + "POST", + "/api/workspaces", + Some(wire.clone()), + StatusCode::FORBIDDEN, + ) + .await; + let created = request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(wire.clone()), + &owner, + StatusCode::CREATED, + ) + .await; + assert!(created["repository"].is_null()); + assert!(!created["replayed"].as_bool().unwrap()); + let id = created["workspace"]["workspace_id"].as_str().unwrap(); + assert!(store.list_repositories(id).unwrap().is_empty()); + let scoped = format!("/api/w/{id}"); + + let replay = request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(wire.clone()), + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(replay["workspace"]["workspace_id"], id); + assert!(replay["replayed"].as_bool().unwrap()); + let repositories = request_json_authenticated( + app.clone(), + "GET", + &format!("{scoped}/repositories"), + None, + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(repositories["items"], json!([])); + assert_eq!(repositories["diagnostics"], json!([])); + request_json_authenticated( + app.clone(), + "GET", + &format!("{scoped}/workspace"), + None, + &owner, + StatusCode::OK, + ) + .await; + let non_owner_workspace = request_json_authenticated( + app.clone(), + "GET", + &format!("{scoped}/workspace"), + None, + &foreign, + StatusCode::OK, + ) + .await; + assert_eq!( + non_owner_workspace["permissions"]["manage_repositories"], + false + ); + assert_eq!( + non_owner_workspace["permissions"]["delete_workspace"], + false + ); + request_json( + app.clone(), + "GET", + &format!("{scoped}/workspace"), + None, + StatusCode::UNAUTHORIZED, + ) + .await; + let foreign_catalog = request_json_authenticated( + app.clone(), + "GET", + "/api/workspaces", + None, + &foreign, + StatusCode::OK, + ) + .await; + assert_eq!(foreign_catalog, json!([])); + let deletion = request_json_authenticated( + app.clone(), + "GET", + &format!("/api/workspaces/{id}/deletion"), + None, + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(deletion["resources"]["repositories"], 0); + assert_eq!( + deletion["blockers"][0]["kind"], "last_accessible_workspace", + "{deletion}" + ); + let worker = request_json_authenticated(app.clone(), "POST", &format!("{scoped}/workers"), Some(json!({"runtime_id": "embedded-worker-runtime", "display_name": "Worker", "initial_submit": [], "feature_connections": {}})), &owner, StatusCode::OK).await; + assert!(worker["worker_id"].is_string(), "{worker}"); + let detail = request_json_authenticated( + app.clone(), + "GET", + &format!( + "{scoped}/runtimes/embedded-worker-runtime/workers/{}", + worker["worker_id"].as_str().unwrap() + ), + None, + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(detail["profile"], "builtin:companion"); + let worker_ref = RuntimeWorkerRef::new( + "embedded-worker-runtime", + worker["worker_id"].as_str().unwrap(), + ); + assert!( + store + .list_worker_workdir_links(id, &worker_ref) + .unwrap() + .is_empty() + ); + assert_eq!(detail["resource_key"], "W-1"); + let repository = json!({"repository_key": "later", "source": "https://example.test/later.git", "default_ref": "main"}); + request_json_authenticated( + app.clone(), + "POST", + &format!("{scoped}/repositories"), + Some(repository.clone()), + &foreign, + StatusCode::FORBIDDEN, + ) + .await; + request_json_authenticated( + app.clone(), + "POST", + &format!("{scoped}/repositories"), + Some(repository), + &owner, + StatusCode::CREATED, + ) + .await; + assert_eq!(store.list_repositories(id).unwrap().len(), 1); + let replay = request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(wire.clone()), + &owner, + StatusCode::OK, + ) + .await; + assert!( + replay["repository"].is_null(), + "omitted initial repository never means a later asset" + ); + let mut conflict = wire.clone(); + conflict["repository"] = + json!({"repository_key": "later", "uri": "https://example.test/later.git"}); + request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(conflict), + &owner, + StatusCode::CONFLICT, + ) + .await; + for repository in [ + json!({"repository_key": "main", "uri": "relative/repo"}), + json!({"repository_key": " main ", "uri": "/runtime/repo"}), + ] { + let invalid = json!({"operation_key": "invalid-empty-repo", "display_name": "Workspace", "repository": repository}); + request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(invalid), + &owner, + StatusCode::BAD_REQUEST, + ) + .await; + } + assert_eq!(store.list_workspaces().unwrap().len(), 1); + // With another accessible Workspace, zero repositories are not a deletion blocker. + let second = request_json_authenticated( + app.clone(), + "POST", + "/api/workspaces", + Some(json!({"operation_key": "second-empty", "display_name": "Second Empty"})), + &owner, + StatusCode::CREATED, + ) + .await; + let second_id = second["workspace"]["workspace_id"].as_str().unwrap(); + let deletion_uri = format!("/api/workspaces/{second_id}/deletion"); + let preflight = request_json_authenticated( + app.clone(), + "GET", + &deletion_uri, + None, + &owner, + StatusCode::OK, + ) + .await; + assert_eq!(preflight["can_delete"], true, "{preflight}"); + assert_eq!(preflight["resources"]["repositories"], 0); + let deletion_request = json!({"operation_id": "delete-second-empty", "expected_revision": preflight["expected_revision"], "confirmation": "Second Empty"}); + request_json_authenticated( + app.clone(), + "POST", + &deletion_uri, + Some(deletion_request.clone()), + &foreign, + StatusCode::FORBIDDEN, + ) + .await; + request_json_authenticated( + app, + "POST", + &deletion_uri, + Some(deletion_request), + &owner, + StatusCode::ACCEPTED, + ) + .await; + } + #[tokio::test] async fn workspace_server_router_requires_identity_for_scoped_rest() { let temp = tempfile::tempdir().unwrap(); @@ -45005,11 +45374,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "create-auth".to_owned(), display_name: "Auth Workspace".to_owned(), - repository: crate::workspace_catalog::InitialRepositoryIntent { + repository: Some(crate::workspace_catalog::InitialRepositoryIntent { repository_key: "main".to_string(), uri: repository.display().to_string(), default_ref: None, - }, + }), }, "account-auth".to_owned(), ) @@ -45135,11 +45504,11 @@ mod tests { let create_request = WorkspaceCreateRequest { operation_key: "create-authenticated-workspace".to_owned(), display_name: "Authenticated Workspace".to_owned(), - repository: crate::workspace_catalog::InitialRepositoryIntent { + repository: Some(crate::workspace_catalog::InitialRepositoryIntent { repository_key: "main".to_string(), uri: created_repository.display().to_string(), default_ref: Some("develop".to_owned()), - }, + }), }; let created_response = app .clone() @@ -46263,11 +46632,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "create-a".to_string(), display_name: "Workspace A".to_string(), - repository: crate::workspace_catalog::InitialRepositoryIntent { + repository: Some(crate::workspace_catalog::InitialRepositoryIntent { repository_key: "main".to_string(), uri: repository_a.display().to_string(), default_ref: None, - }, + }), }, "account-two-workspaces".to_owned(), ) @@ -46277,11 +46646,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "create-b".to_string(), display_name: "Workspace B".to_string(), - repository: crate::workspace_catalog::InitialRepositoryIntent { + repository: Some(crate::workspace_catalog::InitialRepositoryIntent { repository_key: "main".to_string(), uri: repository_b.display().to_string(), default_ref: None, - }, + }), }, "account-two-workspaces".to_owned(), ) @@ -46665,7 +47034,7 @@ mod tests { } #[tokio::test] - async fn mark_ready_accepts_registered_remote_target_and_closes_lifecycle_bypasses() { + async fn progress_and_target_editing_accept_registered_remote_assets_without_git() { let dir = tempfile::tempdir().unwrap(); let api = test_api_with_remote_repository(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); @@ -46711,11 +47080,11 @@ mod tests { .count(), 1 ); - assert!(matches!( - backend.edit_item( - TicketIdOrSlug::Id(ticket_ref.id.clone()), - ticket::TicketItemEdit { - targets: Some(ticket::TicketTargetsEdit::Set { + let edited = backend + .edit_item( + ticket_ref.id.clone().into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Set { targets: vec![test_ticket_target( "test-repository", "other", @@ -46724,20 +47093,67 @@ mod tests { }), ..Default::default() }, - ), - Err(ticket::TicketError::Conflict(_)) - )); - + ) + .unwrap(); + assert_eq!(edited.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!( + edited.meta.targets[0].ref_selector.as_deref(), + Some("other") + ); let mut missing = ticket::NewTicket::new("Missing target"); set_test_ticket_target(&mut missing, "unknown", "develop"); assert!(backend.create(missing).is_err()); - assert!(matches!( - backend.set_workflow_state( - TicketIdOrSlug::Id(ticket_ref.id), - TicketStateChange::new("ready", "queued", "bypass", "must use TicketQueue",), - ), - Err(ticket::TicketError::InvalidWorkflowTransition { .. }) - )); + backend + .set_workflow_state( + ticket_ref.id.into(), + TicketStateChange::new( + "ready", + "inprogress", + "Direct progress judgment", + "No Worker start implied", + ), + ) + .unwrap(); + } + + #[tokio::test] + async fn readiness_accepts_read_only_catalog_reference_without_default_selector() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api_with_remote_repository(dir.path()).await; + let mut repository = api + .store + .get_repository_by_key(TEST_WORKSPACE_ID, "test-repository") + .unwrap() + .unwrap(); + repository.repository_id = "reference-without-default".into(); + repository.repository_key = "reference".into(); + repository.default_ref = None; + api.store.upsert_repository(&repository).unwrap(); + let backend = browser_ticket_backend(&api).unwrap(); + let mut input = ticket::NewTicket::new("Reference only; no checkout decision yet"); + input.targets = vec![ticket::TicketTarget { + repository_key: "reference".into(), + ref_selector: None, + access: TicketTargetAccess::ReadOnly, + }]; + let reference = backend.create(input).unwrap(); + let ready = backend + .mark_ready( + reference.id.clone().into(), + ticket::TicketMarkReady { + operation_key: "reference-ready".into(), + reason: Some("Research ready".into()), + author: Some("workspace-user".into()), + intake_summary: None, + }, + ) + .unwrap(); + assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(ready.meta.targets[0].ref_selector, None); + assert!( + validated_ticket_implementation_targets(&api, &reference.id).is_err(), + "asset operations still require their own grants and selectors" + ); } #[tokio::test] @@ -46916,7 +47332,7 @@ mod tests { }, ]; for operation in preparatory_operations { - execute_ticket_rest_operation(&api, TEST_WORKSPACE_ID, source_headers(), operation) + execute_ticket_rest_operation(&api, TEST_WORKSPACE_ID, HeaderMap::new(), operation) .await .unwrap(); } @@ -46992,6 +47408,408 @@ mod tests { } } + async fn seed_live_test_coder_assignment( + api: &WorkspaceApi, + ticket_id: &str, + assignment_id: &str, + ) -> RuntimeWorkerRef { + let Json(created) = create_workspace_worker( + State(api.clone()), + HeaderMap::new(), + Json(CreateWorkspaceWorkerRequest { + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.to_string(), + display_name: "Assignment lifecycle Coder".to_string(), + singleton_key: None, + profile: Some("builtin:coder".to_string()), + ticket_assignment: None, + initial_submit: Vec::new(), + workdir_attachments: Vec::new(), + feature_connections: Default::default(), + control_operation_id: None, + }), + ) + .await + .unwrap(); + let worker = RuntimeWorkerRef::new(created.runtime_id, created.worker_id); + api.store + .set_current_ticket_coder_assignment( + &TicketCoderAssignmentRecord { + workspace_id: TEST_WORKSPACE_ID.to_string(), + ticket_id: ticket_id.to_string(), + assignment_id: assignment_id.to_string(), + worker: worker.clone(), + assigned_by: "test".to_string(), + assigned_at: TEST_CREATED_AT.to_string(), + }, + None, + &format!("event-{assignment_id}"), + &format!("operation-{assignment_id}"), + false, + ) + .unwrap(); + worker + } + + #[tokio::test] + async fn ticket_decisions_reject_other_workers_and_browser_route_source_bypasses() { + let dir = tempfile::tempdir().unwrap(); + 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); + 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 { + operation_key: "forged".into(), + expected_item_revision: ticket::ticket_item_revision(&before), + expected_state: TicketWorkflowState::InProgress, + reason: "Not my task".into(), + references: Vec::new(), + }; + let response = scoped_complete_ticket( + State(api.clone()), + ticket_check_headers(&other), + AxumPath((TEST_WORKSPACE_ID.into(), ticket.id.clone())), + Json(request), + ) + .await + .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_item_revision: ticket::ticket_item_revision(&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); + } + + #[tokio::test] + async fn assigned_coder_completion_without_mr_replays_and_rejects_changed_decisions() { + let dir = tempfile::tempdir().unwrap(); + 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); + 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 { + operation_key: "research-done".into(), + expected_item_revision: ticket::ticket_item_revision(&before), + expected_state: TicketWorkflowState::InProgress, + reason: "Investigation findings recorded in the Ticket thread".into(), + references: Vec::new(), + }; + let call = |request| { + scoped_complete_ticket( + State(api.clone()), + ticket_check_headers(&coder), + AxumPath((TEST_WORKSPACE_ID.into(), reference.id.clone())), + Json(request), + ) + }; + let Json(completed) = call(request.clone()).await.unwrap(); + assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done); + assert!( + merge_request_store(&api, TEST_WORKSPACE_ID) + .unwrap() + .list_for_ticket(TEST_WORKSPACE_ID, &reference.id) + .unwrap() + .is_empty() + ); + let Json(replayed) = call(request.clone()).await.unwrap(); + assert_eq!(replayed.events, completed.events); + for field in ["reason", "revision", "state", "references"] { + let mut changed = request.clone(); + match field { + "reason" => changed.reason = "different judgment".into(), + "revision" => changed.expected_item_revision = "stale".into(), + "state" => changed.expected_state = TicketWorkflowState::Planning, + "references" => changed.references.push(ticket::TicketReference { + kind: "document".into(), + target: "other".into(), + }), + _ => unreachable!(), + } + let response = call(changed).await.unwrap_err().into_response(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + assert_eq!( + status, + StatusCode::CONFLICT, + "{field}: {}", + String::from_utf8_lossy(&bytes) + ); + } + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &reference.id) + .unwrap() + .is_none() + ); + assert!( + api.store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &reference.id) + .unwrap() + .is_some() + ); + let mut fresh = request; + fresh.operation_key = "unassigned-new-operation".into(); + fresh.expected_state = TicketWorkflowState::Done; + assert!(call(fresh).await.is_err()); + } + + #[tokio::test] + async fn close_and_reopen_api_preserves_responsibility_but_requires_fresh_routing_identity() { + 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); + 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; + assign_test_orchestrator(&api, &ticket.id); + let old_orchestrator_id = format!("orchestrator-{}", ticket.id); + let Json(started) = scoped_start_workspace_orchestrator( + State(api.clone()), + AxumPath(ScopedWorkspacePath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + }), + ) + .await + .unwrap(); + let orchestrator = started.worker.unwrap(); + let orchestrator = RuntimeWorkerRef::new(orchestrator.runtime_id, orchestrator.worker_id); + execution.take_inputs(); + let status = scoped_close_ticket_record( + State(api.clone()), + AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())), + HeaderMap::new(), + Json(ticket::TicketCompletion { + operation_key: "normal-close".into(), + expected_item_revision: api.authority.ticket(&ticket.id).unwrap().item_revision, + expected_state: TicketWorkflowState::InProgress, + reason: "Normal close ends implementation work".into(), + references: Vec::new(), + author: None, + }), + ) + .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], + ) + .unwrap(); + } + let path = || { + AxumPath(ScopedRecordPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + id: ticket.id.clone(), + }) + }; + let Json(detail) = scoped_get_ticket(State(api.clone()), path()).await.unwrap(); + assert_eq!(detail.state, state); + assert_eq!(detail.current_coder.unwrap().assignment_id, "old-coder"); + assert!( + detail + .assignments + .iter() + .any(|assignment| assignment.assignment_id == old_orchestrator_id) + ); + assert_eq!( + detail.action_eligibility.can_assign_orchestrator, + state == "planning" + ); + let Json(assignments) = scoped_list_ticket_assignments(State(api.clone()), path()) + .await + .unwrap(); + assert!( + assignments + .assignments + .iter() + .any(|assignment| assignment.assignment_id == "old-coder") + ); + assert!( + api.store + .list_active_ticket_role_assignments(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_empty(), + "state={state}" + ); + let status = scoped_add_ticket_thread_event( + State(api.clone()), + AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())), + HeaderMap::new(), + Json(NewTicketEvent::new( + TicketEventKind::Comment, + "Browser activity", + )), + ) + .await + .unwrap(); + assert_eq!(status, StatusCode::NO_CONTENT); + assert!(execution.take_inputs().is_empty(), "state={state}"); + scoped_add_ticket_thread_event( + State(api.clone()), + AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())), + ticket_check_headers(&old_coder), + Json(NewTicketEvent::new( + TicketEventKind::Comment, + "Former Coder activity is ordinary Worker activity", + )), + ) + .await + .unwrap(); + let activity = browser_ticket_backend(&api) + .unwrap() + .show(TicketIdOrSlug::Id(ticket.id.clone())) + .unwrap(); + let attributes = &activity.events.last().unwrap().attributes; + assert_eq!( + attributes.get("source_actor_role").map(String::as_str), + Some("worker"), + "state={state}" + ); + assert!( + !attributes.contains_key("source_assignment_id"), + "state={state}" + ); + assert!(execution.take_inputs().is_empty(), "state={state}"); + let response = scoped_open_merge_request( + State(api.clone()), + ticket_check_headers(&old_coder), + AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())), + Json(server_api::OpenMergeRequestRequest { + repository_key: "test-repository".to_string(), + selector_from: "work/old-coder".to_string(), + selector_to: "develop".to_string(), + summary: "Old responsibility is not implementation authority".to_string(), + }), + ) + .await + .unwrap_err() + .into_response(); + let status = response.status(); + let bytes = to_bytes(response.into_body(), usize::MAX).await.unwrap(); + let body = String::from_utf8_lossy(&bytes); + assert_eq!(status, StatusCode::CONFLICT, "state={state}: {body}"); + assert!( + body.contains("Ticket has no active Coder work assignment"), + "state={state}: {body}" + ); + } + execute_ticket_rest_operation( + &api, + TEST_WORKSPACE_ID, + HeaderMap::new(), + TicketBackendOperation::MarkReady { + id: TicketIdOrSlug::Id(ticket.id.clone()), + request: ticket::TicketMarkReady { + operation_key: "reopened-ready".to_string(), + reason: Some("Fresh implementation cycle".to_string()), + author: None, + intake_summary: None, + }, + }, + ) + .await + .unwrap(); + assert!(execution.take_inputs().is_empty()); + let Json(assigned) = scoped_set_ticket_assignment( + State(api.clone()), + AxumPath(( + TEST_WORKSPACE_ID.to_string(), + ticket.id.clone(), + "orchestrator".to_string(), + )), + Json(server_api::SetTicketRoleAssignmentRequest { + operation_id: "fresh-orchestrator-cycle".to_string(), + principal: server_api::TicketAssignmentPrincipal::WorkspaceAgent { + agent_key: "workspace-orchestrator".to_string(), + }, + expected_assignment_id: None, + }), + ) + .await + .unwrap(); + let fresh_id = assigned.assignment.unwrap().assignment_id; + assert_ne!(fresh_id, old_orchestrator_id); + let Json(detail) = scoped_get_ticket( + State(api.clone()), + AxumPath(ScopedRecordPath { + workspace_id: TEST_WORKSPACE_ID.to_string(), + id: ticket.id.clone(), + }), + ) + .await + .unwrap(); + assert!(detail.action_eligibility.can_queue); + assert_eq!(detail.current_coder.unwrap().assignment_id, "old-coder"); + let _ = scoped_queue_ticket_record( + State(api.clone()), + AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())), + HeaderMap::new(), + ) + .await + .unwrap(); + let queued = browser_ticket_backend(&api) + .unwrap() + .show(TicketIdOrSlug::Id(ticket.id.clone())) + .unwrap(); + assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!( + queued + .events + .iter() + .rev() + .find_map(|event| event.attributes.get("orchestrator_assignment_id")), + Some(&fresh_id) + ); + let inputs = execution.take_inputs(); + assert_eq!(inputs.len(), 1); + assert_eq!(inputs[0].0.worker_id.to_string(), orchestrator.worker_id); + assert_eq!( + inputs[0].1, + ticket_notification_content(ticket.resource_key.as_deref().unwrap(), "ready", "queued") + ); + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + } + struct ManualCoderAssignmentFixture { _workspace: tempfile::TempDir, api: WorkspaceApi, @@ -47190,6 +48008,97 @@ mod tests { ); } + #[tokio::test] + async fn active_target_edits_do_not_expand_bound_grants_and_mismatch_fails_closed() { + let fixture = manual_coder_assignment_fixture().await; + let _ = scoped_set_ticket_assignment( + State(fixture.api.clone()), + AxumPath(( + TEST_WORKSPACE_ID.into(), + fixture.ticket_id.clone(), + "coder".into(), + )), + Json(manual_coder_assignment_request( + &fixture, + "bind-original-grants", + )), + ) + .await + .unwrap(); + let backend = browser_ticket_backend(&fixture.api).unwrap(); + let links = fixture + .api + .store + .list_worker_workdir_links(TEST_WORKSPACE_ID, &fixture.worker) + .unwrap(); + let docs_link = links + .iter() + .find(|link| link.workdir_id == fixture.docs_workdir_id) + .unwrap(); + let docs = fixture + .api + .store + .get_workdir_registry(TEST_WORKSPACE_ID, &fixture.docs_workdir_id) + .unwrap() + .unwrap(); + backend + .edit_item( + fixture.ticket_id.clone().into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Set { + targets: vec![ + test_ticket_target( + "test-repository", + "develop", + TicketTargetAccess::ReadWrite, + ), + test_ticket_target("docs", "develop", TicketTargetAccess::ReadWrite), + ], + }), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!( + effective_worker_workdir_capabilities( + &fixture.api, + &fixture.worker, + &docs, + docs_link.capabilities + ) + .unwrap(), + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + assert_eq!( + fixture + .runtime + .logical_attachments(&fixture.worker.worker_id) + .iter() + .find(|attachment| attachment.alias.as_str() == "docs") + .unwrap() + .capabilities, + workdir::WorkdirSessionCapabilities::READ_ONLY + ); + backend + .edit_item( + fixture.ticket_id.into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Clear), + ..Default::default() + }, + ) + .unwrap(); + assert!( + effective_worker_workdir_capabilities( + &fixture.api, + &fixture.worker, + &docs, + docs_link.capabilities + ) + .is_err() + ); + } + #[tokio::test] async fn manual_coder_assignment_rejects_incomplete_or_mismatched_workdirs_before_state_change() { @@ -47980,131 +48889,132 @@ mod tests { } #[tokio::test] - async fn generic_browser_state_mutation_cannot_bypass_assignment_start() { + async fn browser_progress_decisions_allow_direct_completion_and_reopen_with_cas() { + 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 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_item_revision: ticket::ticket_item_revision(&before), + expected_state, + reason: "Human progress judgment".into(), + body: None, + }; + let Json(after) = scoped_transition_ticket_state( + State(api.clone()), + HeaderMap::new(), + AxumPath(ScopedRecordPath { + workspace_id: TEST_WORKSPACE_ID.into(), + id: reference.id.clone(), + }), + Json(request), + ) + .await + .unwrap(); + assert_eq!(after.state, browser_ticket_workflow_state(state).as_str()); + assert!( + api.store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &reference.id) + .unwrap() + .is_none() + ); + } + let before = backend.show(reference.id.clone().into()).unwrap(); + let response = scoped_transition_ticket_state( + 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_item_revision: "stale-revision".into(), + expected_state: server_api::BrowserTicketWorkflowState::Planning, + reason: "Stale decision".into(), + body: None, + }), + ) + .await + .unwrap_err() + .into_response(); + assert_eq!(response.status(), StatusCode::CONFLICT); + assert_eq!(before.meta.workflow_state, TicketWorkflowState::Planning); + } + + #[tokio::test] + async fn explicit_queue_without_assets_or_assignment_records_only_requested_work() { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let ticket = backend - .create(ticket::NewTicket::new("Generic transition guard")) + .create(ticket::NewTicket::new("Queue research")) .unwrap(); - let path = ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - id: ticket.id.clone(), - }; - - let planning = scoped_transition_ticket_state( + let Json(outcome) = scoped_queue_ticket_record( State(api.clone()), - AxumPath(path.clone()), - Json(server_api::BrowserTransitionTicketStateRequest { - state: server_api::BrowserTicketWorkflowState::Inprogress, - reason: None, - body: None, - author: None, - }), - ) - .await - .unwrap_err() - .into_response(); - assert_eq!(planning.status(), StatusCode::CONFLICT); - assert_eq!( - backend - .show(ticket.id.clone().into()) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::Planning - ); - - let mut ready_input = ticket::NewTicket::new("Ready transition guard"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); - let ready = backend.create(ready_input).unwrap(); - let ready_result = scoped_transition_ticket_state( - State(api.clone()), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - id: ready.id.clone(), - }), - Json(server_api::BrowserTransitionTicketStateRequest { - state: server_api::BrowserTicketWorkflowState::Inprogress, - reason: None, - body: None, - author: None, - }), - ) - .await - .unwrap_err() - .into_response(); - assert_eq!(ready_result.status(), StatusCode::CONFLICT); - assert_eq!( - backend.show(ready.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Ready - ); - } - - #[tokio::test] - async fn queue_requires_orchestrator_role_and_records_assignment_fence() { - let dir = tempfile::tempdir().unwrap(); - init_clean_git_workspace(dir.path()); - let api = test_api(dir.path()).await; - let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Queue role gate"); - input.workflow_state = Some(TicketWorkflowState::Ready); - set_test_ticket_target(&mut input, "test-repository", "develop"); - let ticket = backend.create(input).unwrap(); - let path = (TEST_WORKSPACE_ID.to_string(), ticket.id.clone()); - - let legacy_missing = scoped_queue_ticket( - State(api.clone()), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.to_string(), - id: ticket.id.clone(), - }), - Json(server_api::BrowserQueueTicketRequest {}), - ) - .await - .unwrap_err() - .into_response(); - assert_eq!(legacy_missing.status(), StatusCode::CONFLICT); - - let missing = scoped_queue_ticket_record( - State(api.clone()), - AxumPath(path.clone()), + AxumPath((TEST_WORKSPACE_ID.into(), ticket.id.clone())), HeaderMap::new(), ) .await - .unwrap_err() - .into_response(); - assert_eq!(missing.status(), StatusCode::CONFLICT); - assert_eq!( - backend - .show(ticket.id.clone().into()) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::Ready - ); - - assign_test_orchestrator(&api, &ticket.id); - let _ = scoped_queue_ticket_record(State(api.clone()), AxumPath(path), HeaderMap::new()) - .await - .unwrap(); - let queued = backend.show(ticket.id.into()).unwrap(); + .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); - let event = queued.events.last().unwrap(); - let expected_assignment_id = format!("orchestrator-{}", queued.meta.id); - assert_eq!( - event + assert!( + !queued + .events + .last() + .unwrap() .attributes - .get("orchestrator_assignment_id") - .map(String::as_str), - Some(expected_assignment_id.as_str()) + .contains_key("orchestrator_assignment_id") + ); + assert!( + api.store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + // 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); + let fenced = backend.create(input).unwrap(); + assign_test_orchestrator(&api, &fenced.id); + let _ = scoped_queue_ticket_record( + State(api), + AxumPath((TEST_WORKSPACE_ID.into(), fenced.id.clone())), + HeaderMap::new(), + ) + .await + .unwrap(); + let queued = backend.show(fenced.id.clone().into()).unwrap(); + let event = queued.events.last().unwrap(); + assert_eq!( + event.attributes.get("orchestrator_assignment_id"), + Some(&format!("orchestrator-{}", fenced.id)) ); assert!(event.attributes.contains_key("routing_operation_id")); - assert!(event.attributes.contains_key("routing_request_fingerprint")); } #[tokio::test] - async fn queue_reports_dependency_cycle_before_assignment_validation() { + async fn queue_keeps_cycle_diagnostics_without_submitting_other_tickets() { let dir = tempfile::tempdir().unwrap(); init_clean_git_workspace(dir.path()); let api = test_api(dir.path()).await; @@ -48134,24 +49044,33 @@ mod tests { .unwrap(); } - let error = execute_ticket_rest_operation( + let outcome = execute_ticket_rest_operation( &api, TEST_WORKSPACE_ID, HeaderMap::new(), TicketBackendOperation::QueueReady { - id: TicketIdOrSlug::Id(first.id.clone()), - queued_by: "workspace-web".to_string(), + id: first.id.clone().into(), + queued_by: "workspace-web".into(), }, ) .await - .unwrap_err(); - assert!(error.error.to_string().contains("cycle")); - for ticket_id in [first.id, second.id] { - assert_eq!( - backend.show(ticket_id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Ready - ); - } + .unwrap(); + let TicketBackendOperationResult::QueueOutcome(outcome) = outcome else { + panic!("queue result") + }; + assert_eq!(outcome.queued_tickets, vec![first.id.clone()]); + assert_eq!( + backend.show(second.id.into()).unwrap().meta.workflow_state, + TicketWorkflowState::Ready + ); + assert!( + backend + .dependency_check(first.id.into()) + .unwrap() + .blockers + .iter() + .any(|blocker| blocker.reason_kind == "dependency_cycle") + ); } #[tokio::test] @@ -48191,96 +49110,19 @@ mod tests { ) .await .unwrap(); + assert_eq!(outcome.queued_tickets, vec![root.id.clone()]); assert_eq!( - outcome.queued_tickets, - vec![dependency.id.clone(), root.id.clone()] + backend.show(root.id.into()).unwrap().meta.workflow_state, + TicketWorkflowState::Queued ); - for ticket_id in [dependency.id, root.id] { - assert_eq!( - backend.show(ticket_id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Queued - ); - } - } - - #[tokio::test] - async fn queue_requires_assignments_for_every_ready_dependency() { - let dir = tempfile::tempdir().unwrap(); - init_clean_git_workspace(dir.path()); - let api = test_api(dir.path()).await; - let backend = browser_ticket_backend(&api).unwrap(); - let mut dependency_input = ticket::NewTicket::new("Ready dependency"); - dependency_input.workflow_state = Some(TicketWorkflowState::Ready); - set_test_ticket_target(&mut dependency_input, "test-repository", "develop"); - let dependency = backend.create(dependency_input).unwrap(); - let mut root_input = ticket::NewTicket::new("Queue root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); - set_test_ticket_target(&mut root_input, "test-repository", "develop"); - let root = backend.create(root_input).unwrap(); - backend - .add_ticket_relation( - root.id.clone().into(), - ticket::NewTicketRelation { - kind: ticket::TicketRelationKind::DependsOn, - target: dependency.id.clone(), - note: Some("must queue first".to_string()), - author: Some("test".to_string()), - }, - ) - .unwrap(); - - assign_test_orchestrator(&api, &root.id); - let error = scoped_queue_ticket_record( - State(api.clone()), - AxumPath((TEST_WORKSPACE_ID.to_string(), root.id.clone())), - HeaderMap::new(), - ) - .await - .unwrap_err() - .into_response(); - assert_eq!(error.status(), StatusCode::CONFLICT); assert_eq!( backend - .show(root.id.clone().into()) + .show(dependency.id.into()) .unwrap() .meta .workflow_state, TicketWorkflowState::Ready ); - assert_eq!( - backend - .show(dependency.id.clone().into()) - .unwrap() - .meta - .workflow_state, - TicketWorkflowState::Ready - ); - - assign_test_orchestrator(&api, &dependency.id); - let Json(outcome) = scoped_queue_ticket_record( - State(api.clone()), - AxumPath((TEST_WORKSPACE_ID.to_string(), root.id.clone())), - HeaderMap::new(), - ) - .await - .unwrap(); - assert_eq!(outcome.requested_ticket, root.id); - assert_eq!( - outcome.queued_tickets, - vec![dependency.id.clone(), root.id.clone()] - ); - for ticket_id in [dependency.id, root.id] { - let queued = backend.show(ticket_id.clone().into()).unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!( - queued - .events - .last() - .and_then(|event| event.attributes.get("orchestrator_assignment_id")) - .cloned(), - Some(format!("orchestrator-{ticket_id}")) - ); - } } #[tokio::test] @@ -49760,6 +50602,7 @@ mod tests { let Json(ready) = scoped_mark_ticket_ready_from_browser( State(api.clone()), + HeaderMap::new(), AxumPath(path()), Json(InternalTicketMarkReadyRequest { operation_key: "browser-ready".to_owned(), @@ -49786,14 +50629,15 @@ mod tests { let Json(queue_outcome) = scoped_queue_ticket( State(api.clone()), + HeaderMap::new(), AxumPath(path()), Json(server_api::BrowserQueueTicketRequest {}), ) .await .unwrap(); assert_eq!(queue_outcome.requested_ticket, ticket_id); - assert_eq!(queue_outcome.queued_tickets.len(), 2); - assert!(queue_outcome.queued_tickets.contains(&related_ticket_id)); + assert_eq!(queue_outcome.queued_tickets.len(), 1); + assert!(!queue_outcome.queued_tickets.contains(&related_ticket_id)); assert!(queue_outcome.queued_tickets.contains(&ticket_id)); let Json(queued) = scoped_get_ticket(State(api.clone()), AxumPath(path())) .await @@ -49804,8 +50648,12 @@ mod tests { assert_eq!(queued.relations.blockers[0].reason_kind, "depends_on"); let Json(closed) = scoped_close_ticket( State(api), + HeaderMap::new(), AxumPath(path()), Json(server_api::BrowserCloseTicketRequest { + operation_key: "browser-close".into(), + expected_item_revision: queued.item_revision.clone(), + expected_state: server_api::BrowserTicketWorkflowState::Queued, resolution: "Closed through the Browser API.".to_string(), }), ) @@ -53744,7 +54592,7 @@ mod tests { } #[tokio::test] - async fn cleanup_blocks_assigned_worker_before_runtime_deletion() { + async fn cleanup_blocks_unfinished_work_before_runtime_deletion() { let workspace = tempfile::tempdir().unwrap(); init_clean_git_workspace(workspace.path()); let api = test_api(workspace.path()).await; @@ -53760,7 +54608,7 @@ mod tests { .unwrap(); assert_eq!( candidate.blocking_reason.as_deref(), - Some("worker has current Ticket assignment `ticket-assigned` (`coder`)") + Some("worker has unfinished work for Ticket `ticket-assigned` (`coder`)") ); let request = ExecuteRuntimeCleanupRequest { expected_plan_revision: plan.revision.clone(), @@ -53793,6 +54641,93 @@ mod tests { ); } + #[tokio::test] + async fn cleanup_preview_ignores_terminal_ticket_responsibility() { + let workspace = tempfile::tempdir().unwrap(); + let api = test_api(workspace.path()).await; + let worker_id = seed_cleanup_worker(&api, 3, "normal"); + let ticket_id = "terminal-responsibility"; + seed_cleanup_worker_assignment(&api, &worker_id, ticket_id); + let worker = RuntimeWorkerRef::new("runtime-test", &worker_id); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + crate::store::configure_sqlite(&conn).unwrap(); + + for (state, status) in [("done", "open"), ("closed", "closed")] { + conn.execute( + "UPDATE typed_tickets SET workflow_state = ?1, status = ?2 + WHERE workspace_id = ?3 AND ticket_id = ?4", + rusqlite::params![state, status, api.config.workspace_id, ticket_id], + ) + .unwrap(); + assert!( + api.store + .get_current_ticket_role_assignment_for_worker( + &api.config.workspace_id, + &worker, + ) + .unwrap() + .is_some(), + "terminal responsibility must remain visible: {state}" + ); + let plan = build_runtime_cleanup_plan(&api, "runtime-test") + .unwrap_or_else(|err| panic!("cleanup plan: {}", err.error)); + let candidate = plan + .workers + .iter() + .find(|candidate| candidate.worker_id == worker_id) + .unwrap(); + assert_eq!(candidate.blocking_reason, None, "state={state}"); + } + } + + #[tokio::test] + async fn merge_request_assignment_authority_rejects_terminal_ticket_responsibility() { + let workspace = tempfile::tempdir().unwrap(); + let api = test_api(workspace.path()).await; + let worker_id = seed_cleanup_worker(&api, 3, "normal"); + let ticket_id = "terminal-mr-responsibility"; + seed_cleanup_worker_assignment(&api, &worker_id, ticket_id); + let source = MergeRequestAssignmentSource { + store: api.store.clone(), + }; + assert!( + merge_request::AssignmentSource::current_assignment( + &source, + &api.config.workspace_id, + ticket_id, + ) + .unwrap() + .is_some() + ); + let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); + crate::store::configure_sqlite(&conn).unwrap(); + for (state, status) in [("done", "open"), ("closed", "closed")] { + conn.execute( + "UPDATE typed_tickets SET workflow_state = ?1, status = ?2 + WHERE workspace_id = ?3 AND ticket_id = ?4", + rusqlite::params![state, status, api.config.workspace_id, ticket_id], + ) + .unwrap(); + assert!( + api.store + .get_current_ticket_coder_assignment(&api.config.workspace_id, ticket_id) + .unwrap() + .is_some(), + "terminal responsibility must remain visible: {state}" + ); + assert!( + merge_request::AssignmentSource::current_assignment( + &source, + &api.config.workspace_id, + ticket_id, + ) + .unwrap() + .is_none(), + "terminal responsibility must not authorize MR operations: {state}" + ); + } + } + #[tokio::test] async fn cleanup_execution_retains_dirty_and_requires_fresh_provider_not_found() { let workspace = tempfile::tempdir().unwrap(); @@ -56913,7 +57848,7 @@ mod tests { } #[tokio::test] - async fn empty_repository_config_returns_empty_list_with_warning() { + async fn empty_repository_config_returns_valid_empty_list_without_warning() { let root = tempfile::tempdir().unwrap(); let mut config = test_server_config(root.path()); config.repositories.clear(); @@ -56930,10 +57865,7 @@ mod tests { let repositories = get_json(app, "/api/repositories").await; assert!(repositories["items"].as_array().unwrap().is_empty()); - assert_eq!( - repositories["diagnostics"][0]["code"], - "repository_config_empty" - ); + assert_eq!(repositories["diagnostics"], json!([])); } #[tokio::test] diff --git a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs new file mode 100644 index 000000000..d63f64c1a --- /dev/null +++ b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs @@ -0,0 +1,821 @@ +// Included as a child of server::tests so the existing router/fixture helpers +// remain private. These are API regressions, not repository/provider tests: +// every authority record lives in a temporary database, with no Git checkout. +use super::*; + +use merge_request::{ + CompleteMergeRequest, ConflictResolution, MergeRequestAuth, MergeRequestReviewSubject, + MergeRequestStore, MergeStrategy, OpenMergeRequest, RegisterReviewerChildSession, + RequestMergeRequestReview, ReviewDecision, ReviewEvent, SubmitMergeRequestReview, +}; +use rusqlite::{Connection, params}; +use server_api::{ + MergeRequestDetailResponse, MergeRequestListResponse, TicketDetail, TicketQueryResponse, + TicketSourceRefObservation, +}; + +const EVIDENCE_MR: &str = "ticket-evidence-mr"; +const EVIDENCE_SOURCE: &str = "immutable-approved-source"; +const EVIDENCE_TARGET: &str = "immutable-target-result"; + +// Deliberately freeze review dates: freshness must follow the exact item +// revision and source/result snapshot, not a comparison of wall-clock dates. +fn evidence_time() -> chrono::DateTime { + "2000-01-01T00:00:00Z".parse().unwrap() +} + +struct EvidenceApiFixture { + _temp: tempfile::TempDir, + api: WorkspaceApi, + app: Router, + backend: SqliteTicketBackend, + store: MergeRequestStore, + ticket_id: String, + auth: MergeRequestAuth, +} + +impl EvidenceApiFixture { + async fn new() -> Self { + let temp = tempfile::tempdir().unwrap(); + let api = test_api_with_remote_repository(temp.path()).await; + assert!( + api.config + .repositories + .iter() + .all(|repository| repository.path.is_none()) + ); + 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()), + access: TicketTargetAccess::ReadWrite, + }]; + let ticket_id = backend.create(input).unwrap().id; + // Registry/assignment authority only; there is no live Worker or Runtime + // capable of supplying an observation for this fixture. + let worker = RuntimeWorkerRef::new("evidence-offline-runtime", "evidence-coder"); + api.store + .upsert_worker_registry(&WorkerRegistryRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + worker: worker.clone(), + display_name: "Offline evidence Coder".into(), + profile: Some("builtin:coder".into()), + retention_state: "normal".into(), + transcript_ref: None, + session_ref: None, + summary_ref: None, + diagnostics_ref: None, + created_at: evidence_time().to_rfc3339(), + updated_at: evidence_time().to_rfc3339(), + }) + .unwrap(); + let assignment_id = "evidence-assignment".to_string(); + api.store + .set_current_ticket_coder_assignment( + &TicketCoderAssignmentRecord { + workspace_id: TEST_WORKSPACE_ID.into(), + ticket_id: ticket_id.clone(), + assignment_id: assignment_id.clone(), + worker: worker.clone(), + assigned_by: "fixture".into(), + assigned_at: evidence_time().to_rfc3339(), + }, + None, + "evidence-assigned", + "evidence-assignment-operation", + false, + ) + .unwrap(); + let auth = MergeRequestAuth { + workspace_id: TEST_WORKSPACE_ID.into(), + repository_id: test_repository_id(&api), + runtime_id: worker.runtime_id, + worker_id: worker.worker_id, + assignment_id, + }; + let store = merge_request_store(&api, TEST_WORKSPACE_ID).unwrap(); + store + .open_merge_request(OpenMergeRequest { + merge_request_id: EVIDENCE_MR.into(), + ticket_id: ticket_id.clone(), + repository_id: auth.repository_id.clone(), + selector_from: "work/t716".into(), + selector_to: "develop".into(), + summary: String::new(), + auth: auth.clone(), + now: evidence_time(), + }) + .unwrap(); + let app = build_inner_router(api.clone()); + Self { + _temp: temp, + api, + app, + backend, + store, + ticket_id, + auth, + } + } + + fn revision(&self) -> String { + ticket_item_checker::item_revision( + &self.backend.show(self.ticket_id.clone().into()).unwrap(), + ) + } + + fn subjects(&self) -> Vec { + vec![MergeRequestReviewSubject { + merge_request_id: EVIDENCE_MR.into(), + subject_ref: EVIDENCE_SOURCE.into(), + }] + } + + fn approve(&self, token: &str) -> ReviewEvent { + let child_session_id = format!("evidence-reviewer-{token}"); + self.store + .register_reviewer_child_session(RegisterReviewerChildSession { + workspace_id: TEST_WORKSPACE_ID.into(), + parent_runtime_id: self.auth.runtime_id.clone(), + parent_worker_id: self.auth.worker_id.clone(), + child_session_id: child_session_id.clone(), + reviewer_profile: "builtin:reviewer".into(), + now: evidence_time(), + }) + .unwrap(); + self.store + .request_review(RequestMergeRequestReview { + merge_request_id: EVIDENCE_MR.into(), + ticket_id: self.ticket_id.clone(), + ticket_item_revision: self.revision(), + ticket_merge_request_subjects: self.subjects(), + subject_ref: EVIDENCE_SOURCE.into(), + child_session_id, + capability_token: token.into(), + auth: self.auth.clone(), + now: evidence_time(), + }) + .unwrap(); + self.store + .submit_review(SubmitMergeRequestReview { + merge_request_id: EVIDENCE_MR.into(), + ticket_id: self.ticket_id.clone(), + current_subject_ref: EVIDENCE_SOURCE.into(), + capability_token: token.into(), + decision: ReviewDecision::Approve, + body: format!("Requirement approval {token}"), + findings: vec![], + now: evidence_time(), + }) + .unwrap() + } + + fn integrate(&self, approval: &ReviewEvent) -> merge_request::MergeEvent { + self.store + .complete(CompleteMergeRequest { + merge_request_id: EVIDENCE_MR.into(), + ticket_id: self.ticket_id.clone(), + operation_id: "evidence-integration".into(), + approval_event_id: approval.event_id.clone(), + current_subject_ref: EVIDENCE_SOURCE.into(), + target_ref_before: "target-before".into(), + target_ref_after: EVIDENCE_TARGET.into(), + strategy: MergeStrategy::FastForward, + resolution: ConflictResolution::None, + auth: self.auth.clone(), + now: evidence_time(), + }) + .unwrap() + } + + fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion { + ticket::TicketCompletion { + operation_key: "evidence-ticket-completion".into(), + expected_item_revision: self.revision(), + expected_state: TicketWorkflowState::InProgress, + reason: "Implementation judged complete independently of MR attestation".into(), + references: Vec::new(), + author: Some("fixture".into()), + } + } + + async fn show(&self) -> TicketDetail { + serde_json::from_value( + get_json( + self.app.clone(), + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", self.ticket_id), + ) + .await, + ) + .unwrap() + } + + async fn query(&self, filter: Value) -> TicketQueryResponse { + serde_json::from_value( + request_json( + self.app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"), + Some(filter), + StatusCode::OK, + ) + .await, + ) + .unwrap() + } + + async fn assert_filters(&self, approved: bool, missing: bool, stale: bool) { + for (filter, matches) in [ + (json!({"evidence": ["approved_review"]}), approved), + (json!({"attention": ["missing_evidence"]}), missing), + (json!({"attention": ["stale_after_rescope"]}), stale), + ] { + let result = self.query(filter.clone()).await; + let ids = result + .items + .iter() + .map(|item| item.id.as_str()) + .collect::>(); + let expected = if matches { + vec![self.ticket_id.as_str()] + } else { + vec![] + }; + assert_eq!(ids, expected, "{filter}"); + if matches { + let shown = self.show().await; + assert_eq!(result.items[0].item_revision, shown.item_revision); + assert_eq!(result.items[0].evidence, shown.evidence); + assert_eq!(result.items[0].merge_requests, shown.merge_requests); + } + } + } + + async fn list(&self) -> MergeRequestListResponse { + serde_json::from_value( + get_json( + self.app.clone(), + &format!( + "/api/w/{TEST_WORKSPACE_ID}/merge-requests?ticket_ref={}", + self.ticket_id, + ), + ) + .await, + ) + .unwrap() + } + + async fn detail(&self, after: u64, limit: usize) -> MergeRequestDetailResponse { + serde_json::from_value( + get_json( + self.app.clone(), + &format!( + "/api/w/{TEST_WORKSPACE_ID}/merge-requests/{EVIDENCE_MR}?after={after}&limit={limit}", + ), + ) + .await, + ) + .unwrap() + } + + fn rescope(&self) { + self.backend + .edit_item( + self.ticket_id.clone().into(), + TicketItemEdit { + body: Some(MarkdownText::new( + "Revised requirements require a fresh exact-revision approval.", + )), + ..Default::default() + }, + ) + .unwrap(); + } +} + +#[tokio::test] +async fn done_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 revision = fixture.revision(); + let completion = fixture + .backend + .complete(&fixture.ticket_id, fixture.completion(&approval)) + .unwrap(); + assert_eq!(ticket::ticket_item_revision(&completion), revision); + assert!( + fixture + .api + .store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .unwrap() + .is_none() + ); + assert!( + fixture + .api + .store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .unwrap() + .is_some() + ); + + let shown = fixture.show().await; + assert_eq!(shown.state, "done"); + assert_eq!(shown.item_revision, revision); + assert!( + shown.evidence.complete_for_integration, + "{:?}", + shown.evidence.missing + ); + assert!(shown.evidence.review_after_rescope); + assert!(shown.evidence.missing.is_empty()); + assert_eq!(shown.merge_requests.len(), 1); + let summary = &shown.merge_requests[0]; + assert_eq!( + summary.current_subject_ref.as_deref(), + Some(EVIDENCE_SOURCE) + ); + assert_eq!(summary.review_subject_ref.as_deref(), Some(EVIDENCE_SOURCE)); + assert_eq!(summary.review_status, "approved"); + assert_eq!( + summary.source_ref_observation, + TicketSourceRefObservation::NotRequired {} + ); + assert_eq!(summary.integration_evidence_error, None); + fixture.assert_filters(true, false, false).await; + + let listed = fixture.list().await; + assert_eq!(listed.items.len(), 1); + assert_eq!(&listed.items[0].summary, summary); + assert!(listed.items[0].ref_diagnostics.is_empty()); + assert_eq!(listed.items[0].thread_event_count, merge.sequence as usize); + // Neither page includes the MergeEvent. Ref evidence must be computed from + // the full stored thread before applying response-thread pagination. + let first = fixture.detail(0, 1).await; + assert_eq!(first.merge_request.thread.len(), 1); + assert!(matches!( + first.merge_request.thread[0], + server_api::MergeRequestThreadEvent::ReviewRequested(_) + )); + let empty = fixture.detail(merge.sequence, 1).await; + assert!(empty.merge_request.thread.is_empty()); + for detail in [first, empty] { + assert_eq!(detail.source.status, "known"); + assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE)); + assert_eq!(detail.target.status, "known"); + assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET)); + assert_eq!(detail.source.observed_at, merge.created_at.to_rfc3339()); + assert_eq!(detail.source.diagnostic, None); + assert_eq!(detail.target.diagnostic, None); + } +} + +#[tokio::test] +async fn latest_postmerge_approval_requires_exact_revision_and_stored_source_result_snapshot() { + let fixture = EvidenceApiFixture::new().await; + let integration = fixture.approve("integration"); + let merge = fixture.integrate(&integration); + fixture.rescope(); + let revision = fixture.revision(); + assert_ne!(integration.ticket_item_revision, revision); + + let stale = fixture.show().await; + assert!(stale.evidence.approved_current_subject); // integration proof survives + assert!(!stale.evidence.review_after_rescope); + assert!(!stale.evidence.complete_for_integration); + assert!( + stale + .evidence + .missing + .contains(&"review_after_rescope".into()) + ); + fixture.assert_filters(false, true, true).await; + let mut outdated_completion = fixture.completion(&integration); + outdated_completion.expected_item_revision = integration.ticket_item_revision.clone(); + assert!( + fixture + .backend + .complete(&fixture.ticket_id, outdated_completion) + .is_err() + ); + assert_eq!(fixture.show().await.state, "inprogress"); + + let latest = fixture.approve("latest-revision"); + assert_eq!(latest.created_at, integration.created_at); + assert_eq!(latest.ticket_item_revision, revision); + assert_eq!(latest.ticket_merge_request_subjects, fixture.subjects()); + let fresh = fixture.show().await; + assert!( + fresh.evidence.complete_for_integration, + "{:?}", + fresh.evidence.missing + ); + assert_eq!( + fresh.merge_requests[0].review_excerpt.as_deref(), + Some("Requirement approval integration") + ); + fixture.assert_filters(true, false, false).await; + // A current revision alone is insufficient: simulate a stored attestation + // against a different result set using typed serialized payloads in this + // temporary fixture database, never a live Workspace database. + let mut wrong_snapshot = latest.clone(); + wrong_snapshot + .ticket_merge_request_subjects + .push(MergeRequestReviewSubject { + merge_request_id: "other-result".into(), + subject_ref: "other-approved-source".into(), + }); + let mut requested = fixture + .store + .get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR) + .unwrap() + .thread + .into_iter() + .find_map(|event| match event { + merge_request::MergeRequestThreadEvent::ReviewRequested(event) + if event.event_id == latest.request_event_id => + { + Some(event) + } + _ => None, + }) + .unwrap(); + requested.ticket_merge_request_subjects = wrong_snapshot.ticket_merge_request_subjects.clone(); + let connection = Connection::open(&fixture.api.config.database_path).unwrap(); + for (event_id, payload) in [ + ( + &requested.event_id, + serde_json::to_string(&requested).unwrap(), + ), + ( + &wrong_snapshot.event_id, + serde_json::to_string(&wrong_snapshot).unwrap(), + ), + ] { + assert_eq!(connection.execute( + "UPDATE merge_request_thread_events SET payload_json=?4 WHERE workspace_id=?1 AND merge_request_id=?2 AND event_id=?3", + params![TEST_WORKSPACE_ID, EVIDENCE_MR, event_id, payload], + ).unwrap(), 1); + } + drop(connection); + let wrong = fixture.show().await; + 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"); + + let repaired = fixture.approve("exact-snapshot"); + fixture.assert_filters(true, false, false).await; + // The newest requirement approval must not replace the historical approval + // and source which actually authorized integration. + let stored = fixture + .store + .get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR) + .unwrap(); + assert_eq!( + stored.integration_approval().unwrap().event_id, + integration.event_id + ); + assert_eq!(stored.merged_result().unwrap(), &merge); + let detail = fixture.detail(repaired.sequence - 1, 1).await; + assert_eq!(detail.merge_request.thread.len(), 1); + assert_eq!(detail.source.revision_ref.as_deref(), Some(EVIDENCE_SOURCE)); + assert_eq!(detail.target.revision_ref.as_deref(), Some(EVIDENCE_TARGET)); + let completion = fixture + .backend + .complete(&fixture.ticket_id, fixture.completion(&repaired)) + .unwrap(); + assert_eq!(ticket::ticket_item_revision(&completion), revision); + assert_eq!(fixture.show().await.state, "done"); + fixture.assert_filters(true, false, false).await; +} + +#[tokio::test] +async fn open_source_without_runtime_is_typed_unavailable_not_stale_after_rescope() { + let fixture = EvidenceApiFixture::new().await; + let approval = fixture.approve("open-review"); + fixture.rescope(); + // End this assignment identity's work without erasing its responsibility. + // A retained Coder must not silently supply a Runtime for an open source. + let connection = Connection::open(&fixture.api.config.database_path).unwrap(); + assert_eq!(connection.execute( + "INSERT INTO ticket_assignment_work_releases(workspace_id,assignment_id,released_at) VALUES(?1,?2,?3)", + params![TEST_WORKSPACE_ID, fixture.auth.assignment_id, evidence_time().to_rfc3339()], + ).unwrap(), 1); + drop(connection); + assert!( + fixture + .api + .store + .get_current_ticket_coder_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .unwrap() + .is_some() + ); + assert!( + fixture + .api + .store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &fixture.ticket_id) + .unwrap() + .is_none() + ); + + let shown = fixture.show().await; + let summary = &shown.merge_requests[0]; + assert_eq!(summary.state, "open"); + assert_eq!(summary.current_subject_ref, None); + assert_eq!( + summary.source_ref_observation, + TicketSourceRefObservation::Unavailable { + code: "source_ref_runtime_unavailable".into(), + } + ); + assert_eq!(shown.evidence.review_status.as_deref(), Some("unknown")); + assert!(!shown.evidence.complete_for_integration); + assert!( + shown + .evidence + .missing + .contains(&"source_ref_unavailable".into()) + ); + assert!( + !shown + .evidence + .missing + .contains(&"review_after_rescope".into()) + ); + fixture.assert_filters(false, true, false).await; + let listed = fixture.list().await; + assert_eq!(listed.items.len(), 1); + assert_eq!(&listed.items[0].summary, summary); + assert_eq!( + listed.items[0].ref_diagnostics[0].code, + "source_ref_runtime_unavailable" + ); + let detail = fixture.detail(approval.sequence, 1).await; + assert!(detail.merge_request.thread.is_empty()); + assert_eq!(detail.source.status, "unknown"); + assert_eq!(detail.source.revision_ref, None); + assert_eq!( + detail.source.diagnostic.unwrap().code, + "source_ref_runtime_unavailable" + ); + assert_eq!(detail.target.status, "unknown"); + assert_eq!( + detail.target.diagnostic.unwrap().code, + "target_ref_runtime_unavailable" + ); +} + +#[tokio::test] +async fn evidence_query_continues_bounded_scans_past_nonmatching_tickets() { + let fixture = EvidenceApiFixture::new().await; + let approval = fixture.approve("integration"); + fixture.integrate(&approval); + for title in [ + "A nonmatching one", + "B nonmatching two", + "C nonmatching three", + ] { + fixture + .backend + .create(ticket::NewTicket::new(title)) + .unwrap(); + } + for filter in [ + json!({"evidence": ["approved_review"]}), + json!({"review_status": "approved"}), + ] { + let mut request = filter.clone(); + request["sort"] = json!("title"); + request["limit"] = json!(1); + let first = fixture.query(request.clone()).await; + assert!(first.items.is_empty()); + assert!(first.page.has_more); + assert!(first.page.source_truncated); + request["cursor"] = json!(first.page.next_cursor.unwrap()); + let second = fixture.query(request).await; + assert_eq!(second.items.len(), 1); + assert_eq!(second.items[0].id, fixture.ticket_id); + assert!(second.items[0].evidence.review_after_rescope); + assert!(!second.page.has_more); + } +} + +#[test] +fn source_observation_diagnostics_preserve_provider_failure_codes() { + for (provider_code, expected) in [ + ("repository_ref_not_found", "source_ref_not_found"), + ( + "repository_ref_provider_timeout", + "source_ref_provider_timeout", + ), + ( + "repository_ref_provider_unavailable", + "source_ref_provider_unavailable", + ), + ( + "repository_access_credential_expired", + "source_ref_credential_expired", + ), + ] { + let error = Error::RuntimeOperationFailed { + runtime_id: "fixture-runtime".into(), + code: provider_code.into(), + message: "Provider observation failed".into(), + }; + assert_eq!( + merge_ref_diagnostic(remap_source_ref_error(error.into())).code, + expected + ); + } +} + +#[tokio::test] +async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_state() { + let fixture = EvidenceApiFixture::new().await; + let before = fixture + .store + .get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR) + .unwrap(); + fixture + .backend + .update_state( + &fixture.ticket_id, + ticket::TicketStateUpdate { + operation_key: "close-with-open-mr".into(), + expected_item_revision: fixture.revision(), + expected_state: TicketWorkflowState::InProgress, + state: TicketWorkflowState::Closed, + reason: "The request was withdrawn; no integration or approval is asserted".into(), + references: Vec::new(), + author: Some("workspace-user".into()), + }, + ) + .unwrap(); + let after = fixture + .store + .get_by_id(TEST_WORKSPACE_ID, EVIDENCE_MR) + .unwrap(); + assert_eq!( + serde_json::to_value(after).unwrap(), + serde_json::to_value(before).unwrap() + ); + let shown = fixture.show().await; + assert_eq!(shown.state, "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() { + 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("Investigation with no repository")) + .unwrap(); + let ticket = backend.show(reference.id.clone().into()).unwrap(); + backend + .complete( + &reference.id, + ticket::TicketCompletion { + operation_key: "research-result".into(), + expected_item_revision: ticket::ticket_item_revision(&ticket), + expected_state: TicketWorkflowState::Planning, + reason: "Answer recorded in the Ticket thread".into(), + references: Vec::new(), + author: Some("workspace-user".into()), + }, + ) + .unwrap(); + let app = build_inner_router(api); + let shown: TicketDetail = serde_json::from_value( + get_json( + app.clone(), + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id), + ) + .await, + ) + .unwrap(); + assert_eq!(shown.state, "done"); + assert!(!shown.evidence.has_merge_request); + assert!(!shown.evidence.approved_current_subject); + assert!(!shown.evidence.complete_for_integration); + assert!(shown.evidence.missing.is_empty()); + assert!(shown.evidence.review_status.is_none()); + for filter in [ + json!({"attention":["missing_evidence"]}), + json!({"evidence":["approved_review"]}), + ] { + let query: TicketQueryResponse = serde_json::from_value( + request_json( + app.clone(), + "POST", + &format!("/api/w/{TEST_WORKSPACE_ID}/tickets/query"), + Some(filter), + StatusCode::OK, + ) + .await, + ) + .unwrap(); + assert!(query.items.is_empty()); + } +} + +#[tokio::test] +async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_receipts() { + 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("Transport decisions")) + .unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + let app = build_inner_router(api); + let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id); + for suffix in ["workflow-state", "state-changes", "state-fields/state"] { + request_json( + app.clone(), + "POST", + &format!("{path}/{suffix}"), + Some(json!({ + "from":"planning","to":"done","reason":"old unguarded request","body":"" + })), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + } + request_json( + app.clone(), + "POST", + &format!("{path}/workflow/close"), + Some(json!("unguarded close")), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + let close = json!({ + "operation_key":"guarded-close","expected_item_revision":ticket::ticket_item_revision(&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")); + assert_eq!( + receipt.attributes.get("operation_key"), + Some(&"guarded-close".to_string()) + ); + let reopen = json!({ + "operation_key":"reopen","expected_item_revision":ticket::ticket_item_revision(&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 + .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(), + ) + .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); +} diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 13d19cb7f..c3e242f29 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 = 81; +const LATEST_SCHEMA_VERSION: i64 = 82; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; const WORKSPACE_RUNTIME_BINDINGS_MIGRATION_NAME: &str = "workspace runtime bindings"; @@ -283,6 +283,11 @@ const MIGRATIONS: &[Migration] = &[ name: "Backend Job immutable resource serialization", apply: migrate_backend_job_resources_v80_to_v81, }, + Migration { + version: 82, + name: "Ticket responsibility and unfinished work separation", + apply: migrate_assignment_work_v81_to_v82, + }, ]; #[derive(Clone, Copy)] @@ -379,13 +384,13 @@ pub struct WorkspaceBootstrapRecord { pub operation_key: String, pub request_fingerprint: String, pub workspace: WorkspaceRecord, - pub repository: RepositoryRecord, + pub repository: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub struct WorkspaceBootstrapResult { pub workspace: WorkspaceRecord, - pub repository: RepositoryRecord, + pub repository: Option, pub config_revision: u64, pub replayed: bool, } @@ -1853,6 +1858,26 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { workspace_id: &str, operation_id: &str, ) -> Result<()>; + /// Operational authority/occupancy only. Terminal state releases these + /// identities durably; reopening does not reactivate retained responsibility. + fn list_active_ticket_role_assignments( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result>; + fn get_active_ticket_role_assignment_for_worker( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result>; + fn get_active_ticket_role_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + role: TicketAssignmentRole, + ) -> Result>; + /// Retained responsibility, including ended work and removed principals. + /// Use the active variants for admission, routing, authorization or removal. fn list_current_ticket_role_assignments( &self, workspace_id: &str, @@ -1907,6 +1932,11 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore { occurred_at: &str, reason: &str, ) -> Result; + fn get_active_ticket_worker_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result>; fn get_current_ticket_coder_assignment( &self, workspace_id: &str, @@ -4401,7 +4431,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { signing_identity: &WorkspaceSigningIdentityActivation, identity_provisioning_operation_key: &str, ) -> Result { - validate_repository_record_identity(&record.repository)?; + if let Some(repository) = &record.repository { + validate_repository_record_identity(repository)?; + if repository.workspace_id != record.workspace.workspace_id { + return Err(Error::Store( + "Initial Repository does not belong to the Workspace bootstrap".to_string(), + )); + } + } if signing_identity.workspace_id != record.workspace.workspace_id { return Err(Error::Store( "Workspace signing identity does not belong to the Workspace bootstrap".to_string(), @@ -4442,14 +4479,14 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![workspace_id], read_workspace_record, )?; - let repository = tx.query_row( + let repository = if let Some(initial) = &record.repository { tx.query_row( r#"SELECT workspace_id, repository_id, repository_key, kind, provider, source_kind, source_uri, default_ref, source_revision, source_fingerprint, observed_status, observed_at, created_at, updated_at FROM repositories WHERE workspace_id = ?1 AND repository_key = ?2"#, - params![workspace.workspace_id, record.repository.repository_key], + params![workspace.workspace_id, initial.repository_key], read_repository_record, - )?; + ).optional()? } else { None }; let persisted_identity = tx.query_row( r#"SELECT workspace_id, key_id, algorithm, public_key, public_key_fingerprint, private_material_ref, revision, state, @@ -4536,6 +4573,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.workspace.updated_at, ], )?; + if let Some(repository) = &record.repository { tx.execute( r#"INSERT INTO repositories ( workspace_id, repository_id, repository_key, kind, provider, uri, @@ -4543,23 +4581,24 @@ impl ControlPlaneStore for SqliteWorkspaceStore { source_fingerprint, observed_status, observed_at, created_at, updated_at ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14, ?15)"#, params![ - record.repository.workspace_id, - record.repository.repository_id, - record.repository.repository_key, - record.repository.kind, - record.repository.provider, - record.repository.source.uri, - record.repository.source.kind.as_str(), - record.repository.source.uri, - record.repository.default_ref, - record.repository.source_revision, - record.repository.source_fingerprint, - record.repository.observed_status.as_str(), - record.repository.observed_at, - record.repository.created_at, - record.repository.updated_at, + repository.workspace_id, + repository.repository_id, + repository.repository_key, + repository.kind, + repository.provider, + repository.source.uri, + repository.source.kind.as_str(), + repository.source.uri, + repository.default_ref, + repository.source_revision, + repository.source_fingerprint, + repository.observed_status.as_str(), + repository.observed_at, + repository.created_at, + repository.updated_at, ], )?; + } } if crate::config_source::load_state(&tx, &record.workspace.workspace_id)?.is_none() { let state = crate::config_source::initial_state()?; @@ -8026,6 +8065,69 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn list_active_ticket_role_assignments( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result> { + self.with_conn(|conn| { + let sql = active_ticket_role_assignment_select_sql( + "WHERE current.workspace_id = ?1 AND current.ticket_id = ?2 \ + ORDER BY current.role, a.assigned_at, a.assignment_id", + ); + let mut stmt = conn.prepare(&sql)?; + let rows = stmt.query_map( + params![workspace_id, ticket_id], + read_ticket_role_assignment_record, + )?; + rows.collect::>>() + .map_err(Into::into) + }) + } + + fn get_active_ticket_role_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + role: TicketAssignmentRole, + ) -> Result> { + self.with_conn(|conn| { + let sql = active_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 LIMIT 1", + ); + Ok(conn + .query_row( + &sql, + params![workspace_id, ticket_id, role.as_str()], + read_ticket_role_assignment_record, + ) + .optional()?) + }) + } + + fn get_active_ticket_role_assignment_for_worker( + &self, + workspace_id: &str, + worker: &RuntimeWorkerRef, + ) -> Result> { + self.with_conn(|conn| { + let sql = active_ticket_role_assignment_select_sql( + "WHERE current.workspace_id = ?1 \ + AND current.principal_kind = 'worker' \ + AND current.runtime_id = ?2 AND current.worker_id = ?3 \ + ORDER BY a.assigned_at, a.assignment_id LIMIT 1", + ); + Ok(conn + .query_row( + &sql, + params![workspace_id, worker.runtime_id, worker.worker_id], + read_ticket_role_assignment_record, + ) + .optional()?) + }) + } + fn list_current_ticket_role_assignments( &self, workspace_id: &str, @@ -8203,12 +8305,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore { } Some(expected.clone()) } else if record.role.is_singleton() && !current.is_empty() { - return Err(Error::TicketAssignmentConflict(format!( - "Ticket `{}` already has an active `{}` assignment `{}`", - record.ticket_id, - record.role.as_str(), - current[0].assignment_id - ))); + 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 }; @@ -8434,7 +8539,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )); } let conflicting: i64 = tx.query_row( - "SELECT COUNT(*) FROM ticket_current_worker_assignments + "SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE workspace_id = ?1 AND ticket_id = ?2 AND role IN ('orchestrator', 'coder')", params![record.workspace_id, record.ticket_id], @@ -8447,6 +8552,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { )); } + let previous: Option = tx.query_row( + "SELECT assignment_id FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder'", + params![record.workspace_id, record.ticket_id], |row| row.get(0)).optional()?; + tx.execute("DELETE FROM ticket_current_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder'", + params![record.workspace_id, record.ticket_id])?; tx.execute( "INSERT INTO ticket_assignment_operations ( workspace_id, operation_id, action, ticket_id, role, principal_kind, @@ -8478,10 +8588,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { tx.execute( "INSERT INTO ticket_worker_assignment_events ( workspace_id, ticket_id, role, event_id, action, assignment_id, - actor, created_at, operation_id - ) VALUES (?1, ?2, 'coder', ?3, 'assigned', ?4, ?5, ?6, ?7)", + actor, created_at, operation_id, previous_assignment_id + ) VALUES (?1, ?2, 'coder', ?3, ?8, ?4, ?5, ?6, ?7, ?9)", params![record.workspace_id, record.ticket_id, event_id, record.assignment_id, - record.assigned_by, record.assigned_at, operation_id], + record.assigned_by, record.assigned_at, operation_id, + if previous.is_some() { "reassigned" } else { "assigned" }, previous], )?; let event_index: i64 = tx.query_row( "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events @@ -8649,6 +8760,27 @@ impl ControlPlaneStore for SqliteWorkspaceStore { }) } + fn get_active_ticket_worker_assignment( + &self, + workspace_id: &str, + ticket_id: &str, + ) -> Result> { + self.with_conn(|conn| { + conn.query_row( + current_ticket_worker_assignment_select_sql() + .replace( + "ticket_current_worker_assignments", + "ticket_active_worker_assignments", + ) + .as_str(), + params![workspace_id, ticket_id], + read_ticket_worker_assignment_record, + ) + .optional() + .map_err(Error::from) + }) + } + fn get_current_ticket_coder_assignment( &self, workspace_id: &str, @@ -8753,7 +8885,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { read_ticket_worker_assignment_record, ) .optional()?; - if previous.is_some() && !allow_reassign { + let previous_active: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE workspace_id=?1 AND ticket_id=?2 AND role='coder')", + params![record.workspace_id, record.ticket_id], |row| row.get(0))?; + if previous_active && !allow_reassign { return Err(Error::TicketAssignmentConflict(format!( "Ticket {} is already assigned; use the explicit reassign operation", record.ticket_id @@ -8790,7 +8925,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { record.assigned_at, ], )?; - let current_write = if allow_reassign { + let current_write = if previous.is_some() { tx.execute( r#"UPDATE ticket_current_worker_assignments SET assignment_id = ?3, runtime_id = ?4, worker_id = ?5, updated_at = ?6 @@ -10633,7 +10768,7 @@ fn runtime_removal_preflight( ), ( "active_worker_assignment", - "SELECT EXISTS(SELECT 1 FROM ticket_current_worker_assignments WHERE runtime_id = ?1)", + "SELECT EXISTS(SELECT 1 FROM ticket_active_worker_assignments WHERE runtime_id = ?1)", ), ( "active_workdir", @@ -11743,6 +11878,13 @@ fn read_ticket_role_assignment_by_id( .optional()?) } +fn active_ticket_role_assignment_select_sql(where_clause: &str) -> String { + ticket_role_assignment_select_sql(where_clause).replace( + "ticket_current_worker_assignments", + "ticket_active_worker_assignments", + ) +} + fn ticket_role_assignment_select_sql(where_clause: &str) -> String { format!( "SELECT a.workspace_id, a.ticket_id, a.assignment_id, a.role, \ @@ -11982,7 +12124,7 @@ fn read_assignment_operation( fn map_assignment_constraint(error: rusqlite::Error, ticket_id: &str, worker_id: &str) -> Error { if matches!(error, rusqlite::Error::SqliteFailure(_, _)) { Error::TicketAssignmentConflict(format!( - "Ticket {ticket_id} or Worker {worker_id} already has a current assignment" + "Ticket {ticket_id} has a responsibility conflict or Worker {worker_id} has unfinished work" )) } else { Error::Sqlite(error) @@ -15128,6 +15270,99 @@ fn migrate_archive_observe_grants_v75_to_v76(conn: &Connection) -> Result<()> { Ok(()) } +fn migrate_assignment_work_v81_to_v82(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 81 { + return Err(Error::Store( + "Ticket work separation requires schema 81".into(), + )); + } + let foreign_keys = + conn.pragma_query_value(None, "foreign_keys", |row| row.get::<_, bool>(0))?; + let legacy = + conn.pragma_query_value(None, "legacy_alter_table", |row| row.get::<_, bool>(0))?; + conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;")?; + let result = (|| { + let tx = rusqlite::Transaction::new_unchecked(conn, TransactionBehavior::Exclusive)?; + let dependents = { + let mut stmt = tx.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='ticket_current_worker_assignments' AND type IN ('index','trigger') AND sql IS NOT NULL AND name != 'ticket_current_worker_role_idx'")?; + let rows = stmt.query_map([], |row| row.get::<_, String>(0))?; + rows.collect::>>()? + }; + let schema = include_str!("latest_schema.sql"); + let start = schema + .find("CREATE TABLE ticket_current_worker_assignments (") + .ok_or_else(|| Error::Store("Missing assignment schema".into()))?; + let end = schema[start..] + .find("CREATE TABLE ticket_worker_assignment_events") + .ok_or_else(|| Error::Store("Missing assignment schema end".into()))? + + start; + tx.execute_batch(&schema[start..end].replace( + "CREATE TABLE ticket_current_worker_assignments", + "CREATE TABLE ticket_current_worker_assignments_v82", + ))?; + tx.execute_batch("INSERT INTO ticket_current_worker_assignments_v82 SELECT * FROM ticket_current_worker_assignments; + DROP TABLE ticket_current_worker_assignments; + ALTER TABLE ticket_current_worker_assignments_v82 RENAME TO ticket_current_worker_assignments;")?; + for sql in dependents { + tx.execute_batch(&sql)?; + } + tx.execute_batch(include_str!("assignment_work.sql"))?; + // Existing done/closed + current-assignment data is preserved, and a + // durable release prevents a later reopen from resurrecting old work. + tx.execute_batch("INSERT OR IGNORE INTO ticket_assignment_work_releases + SELECT current.workspace_id, current.assignment_id, COALESCE(ticket.updated_at, current.updated_at) + FROM ticket_current_worker_assignments current JOIN typed_tickets ticket + ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id + WHERE ticket.workflow_state IN ('done','closed');")?; + let legacy_plans = { + let mut statement = + tx.prepare("SELECT operation_id, blockers_json FROM worker_removal_operations")?; + let rows = statement.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + rows.collect::>>()? + }; + for (operation_id, encoded) in legacy_plans { + let mut blockers: Vec = serde_json::from_str(&encoded) + .map_err(|error| Error::Store(format!("invalid removal blockers: {error}")))?; + let mut changed = false; + for blocker in &mut blockers { + if blocker.get("kind").and_then(serde_json::Value::as_str) + == Some("current_assignment") + { + blocker["kind"] = serde_json::Value::String("unfinished_work".into()); + changed = true; + } + } + if changed { + let encoded = serde_json::to_string(&blockers) + .map_err(|error| Error::Store(error.to_string()))?; + // Historical blocked plans stay blocked; issued fingerprints + // and identities are immutable. Fresh planning rereads active work. + tx.execute( + "UPDATE worker_removal_operations SET blockers_json=?2 WHERE operation_id=?1", + params![operation_id, encoded], + )?; + } + } + let broken: i64 = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + if broken != 0 { + return Err(Error::Store( + "Ticket work migration foreign-key verification failed".into(), + )); + } + tx.execute("INSERT INTO __yoi_schema_migrations(version,name) VALUES (82,'Ticket responsibility and unfinished work separation')", [])?; + tx.commit()?; + Ok(()) + })(); + conn.pragma_update(None, "legacy_alter_table", legacy)?; + conn.pragma_update(None, "foreign_keys", foreign_keys)?; + result +} + fn migrate_backend_job_resources_v80_to_v81(conn: &Connection) -> Result<()> { if current_schema_version(conn)? != 80 { return Err(Error::Store( @@ -15371,6 +15606,7 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { )); "#, )?; + conn.execute_batch(include_str!("assignment_work.sql"))?; ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { Error::Store(format!( "fresh Workspace Ticket schema verification failed: {error}" @@ -15403,6 +15639,32 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { ))); } } + if current_schema_version(conn)? >= 82 { + for (kind, name) in [ + ("table", "ticket_assignment_work_releases"), + ("view", "ticket_active_worker_assignments"), + ("trigger", "ticket_terminal_releases_work"), + ("trigger", "ticket_terminal_assignment_releases_work_insert"), + ("trigger", "ticket_terminal_assignment_releases_work_update"), + ("trigger", "ticket_active_worker_role_insert"), + ("trigger", "ticket_active_worker_role_update"), + ("trigger", "ticket_active_worker_principal_insert"), + ("trigger", "ticket_active_worker_principal_update"), + ("trigger", "ticket_unfinished_work_blocks_worker_delete"), + ("trigger", "ticket_unfinished_work_blocks_worker_move"), + ] { + let exists: 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 !exists { + return Err(Error::Store(format!( + "unfinished Ticket work constraint {name} is missing" + ))); + } + } + } Ok(()) } @@ -17212,6 +17474,9 @@ mod tests { let store = SqliteWorkspaceStore::open(&path).unwrap(); store .with_conn(|conn| { + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;")?; + downgrade_assignment_work_schema(conn); + conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;")?; downgrade_schema_66_ticket_and_workdir_authority(conn)?; conn.execute_batch( "DROP TRIGGER runtime_binding_insert_blocked_by_removal; \ @@ -17281,6 +17546,11 @@ mod tests { drop(SqliteWorkspaceStore::open(&path).unwrap()); { let conn = Connection::open(&path).unwrap(); + conn.execute_batch("PRAGMA foreign_keys=OFF; PRAGMA legacy_alter_table=ON;") + .unwrap(); + downgrade_assignment_work_schema(&conn); + conn.execute_batch("PRAGMA legacy_alter_table=OFF; PRAGMA foreign_keys=ON;") + .unwrap(); downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap(); conn.execute_batch( "ALTER TABLE worker_removal_operations \ @@ -17659,6 +17929,44 @@ mod tests { .unwrap(); } + fn downgrade_assignment_work_schema(conn: &Connection) { + conn.execute_batch( + r#" +DROP TRIGGER ticket_terminal_releases_work; +DROP TRIGGER ticket_terminal_assignment_releases_work_insert; +DROP TRIGGER ticket_terminal_assignment_releases_work_update; +DROP TRIGGER ticket_active_worker_role_insert; +DROP TRIGGER ticket_active_worker_role_update; +DROP TRIGGER ticket_active_worker_principal_insert; +DROP TRIGGER ticket_active_worker_principal_update; +DROP TRIGGER ticket_unfinished_work_blocks_worker_delete; +DROP TRIGGER ticket_unfinished_work_blocks_worker_move; +DROP VIEW ticket_active_worker_assignments; +DROP TABLE ticket_assignment_work_releases; +CREATE UNIQUE INDEX ticket_current_worker_role_idx + ON ticket_current_worker_assignments(workspace_id, role, runtime_id, worker_id) + WHERE principal_kind='worker'; +"#, + ) + .unwrap(); + // Restore the actual old FK contract, not just the old unique index. + let dependents = { + let mut stmt = conn.prepare("SELECT sql FROM sqlite_schema WHERE tbl_name='ticket_current_worker_assignments' AND type IN ('index','trigger') AND sql IS NOT NULL").unwrap(); + stmt.query_map([], |row| row.get::<_, String>(0)) + .unwrap() + .collect::>>() + .unwrap() + }; + let table: String = conn.query_row("SELECT sql FROM sqlite_schema WHERE type='table' AND name='ticket_current_worker_assignments'", [], |row| row.get(0)).unwrap(); + let old_table = table.replace("CREATE TABLE ticket_current_worker_assignments", "CREATE TABLE current_assignments_old") + .replace(" CHECK(\n", " FOREIGN KEY(workspace_id,runtime_id,worker_id) REFERENCES worker_registry(workspace_id,runtime_id,worker_id) ON DELETE RESTRICT,\n CHECK(\n"); + conn.execute_batch(&old_table).unwrap(); + conn.execute_batch("INSERT INTO current_assignments_old SELECT * FROM ticket_current_worker_assignments; DROP TABLE ticket_current_worker_assignments; ALTER TABLE current_assignments_old RENAME TO ticket_current_worker_assignments;").unwrap(); + for sql in dependents { + conn.execute_batch(&sql).unwrap(); + } + } + fn prepare_schema_v52(path: &Path) { let conn = Connection::open(path).unwrap(); configure_sqlite(&conn).unwrap(); @@ -17667,6 +17975,7 @@ mod tests { conn.execute_batch("PRAGMA foreign_keys = OFF; PRAGMA legacy_alter_table = ON;") .unwrap(); create_latest_workspace_schema(&conn).unwrap(); + downgrade_assignment_work_schema(&conn); downgrade_schema_66_ticket_and_workdir_authority(&conn).unwrap(); conn.execute_batch("PRAGMA legacy_alter_table = OFF; PRAGMA foreign_keys = ON;") .unwrap(); @@ -17928,6 +18237,10 @@ mod tests { version: 81, name: "Backend Job immutable resource serialization".to_string() }, + WorkspaceSchemaMigrationStep { + version: 82, + name: "Ticket responsibility and unfinished work separation".to_string() + }, ] ); @@ -18019,6 +18332,7 @@ mod tests { (79, WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME.to_string()), (80, WORKER_RESTORE_INTENTS_MIGRATION_NAME.to_string()), (81, "Backend Job immutable resource serialization".to_string()), + (82, "Ticket responsibility and unfinished work separation".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -18370,7 +18684,7 @@ mod tests { .collect::>(), vec![ 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, - 73, 74, 75, 76, 77, 78, 79, 80, 81 + 73, 74, 75, 76, 77, 78, 79, 80, 81, 82 ] ); SqliteWorkspaceStore::migrate_database(&path).unwrap(); @@ -18379,7 +18693,7 @@ mod tests { current_schema_version(&conn).unwrap(), LATEST_SCHEMA_VERSION ); - assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 32); + assert_eq!(workspace_schema_migration_history(&conn).unwrap().len(), 33); assert!(column_exists(&conn, "worker_workdir_links", "alias").unwrap()); assert!(column_exists(&conn, "worker_workdir_links", "capabilities").unwrap()); assert!(!column_exists(&conn, "worker_workdir_links", "role").unwrap()); @@ -20473,6 +20787,203 @@ mod tests { assert_eq!(pinned.definition.name, "coder-review"); } + // Equivalent to the reported closed T-12 + retained Coder data. The + // migration fixture is a real pre-cutover schema, not the live Workspace. + #[test] + fn schema_v81_terminal_responsibility_survives_migration_reopen_and_worker_deletion() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("workspace.db"); + prepare_schema_v50(&path, Some("workspace-a")); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + for migration in MIGRATIONS.iter().filter(|m| m.version <= 81) { + (migration.apply)(&conn).unwrap(); + } + conn.execute_batch(r#" + INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) + VALUES('workspace-a','runtime-a','old-coder','Coder T-12','normal','1','1'); + INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,updated_at) + VALUES('workspace-a','closed-ticket','closed-ticket','Closed Ticket','closed','task','normal','','closed',1,'2'); + INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) + VALUES('workspace-a','closed-ticket','old-assignment','runtime-a','old-coder','actor','1'); + INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,updated_at) + VALUES('workspace-a','closed-ticket','old-assignment','runtime-a','old-coder','1'); + INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,event_id,action,assignment_id,actor,created_at) + VALUES('workspace-a','closed-ticket','assigned-event','assigned','old-assignment','actor','1'); + "#).unwrap(); + let legacy_blockers = serde_json::json!([ + {"kind":"hold"}, {"kind":"current_assignment","assignment_id":"current_assignment","ticket_id":"closed-ticket"} + ]).to_string(); + conn.execute("INSERT INTO worker_removal_operations(operation_id,plan_id,input_fingerprint,workspace_id,runtime_id,worker_id,worker_revision,policy_id,policy_revision,session_disposition,metadata_disposition,archive_retention_kind,diagnostics_disposition,blockers_json,state,reason,created_at,updated_at) + VALUES('old-op','old-plan','immutable-fingerprint','workspace-a','runtime-a','old-coder','1','workspace-default-conservative',1,'purge','tombstone','forever','purge',?1,'blocked','retain','1','1')", [&legacy_blockers]).unwrap(); + migrate_assignment_work_v81_to_v82(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 82); + apply_migrations(&conn).unwrap(); // canonical retry is a no-op + let (plan, fingerprint, state, encoded): (String,String,String,String) = conn.query_row( + "SELECT plan_id,input_fingerprint,state,blockers_json FROM worker_removal_operations WHERE operation_id='old-op'", [], + |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?))).unwrap(); + assert_eq!( + (plan.as_str(), fingerprint.as_str(), state.as_str()), + ("old-plan", "immutable-fingerprint", "blocked") + ); + let blockers: Vec = + serde_json::from_str(&encoded).unwrap(); + assert!( + 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'; + DELETE FROM worker_registry WHERE worker_id='old-coder';", + ) + .unwrap(); + let (responsibility, active, history, tombstone): (i64, i64, i64, i64) = conn + .query_row( + "SELECT (SELECT COUNT(*) FROM ticket_current_worker_assignments), + (SELECT COUNT(*) FROM ticket_active_worker_assignments), + (SELECT COUNT(*) FROM ticket_worker_assignment_events), + (SELECT COUNT(*) FROM ticket_assignment_worker_tombstones)", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .unwrap(); + assert_eq!((responsibility, active, history, tombstone), (1, 0, 1, 1)); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 0 + ); + assert!( + conn.execute( + "UPDATE ticket_current_worker_assignments SET updated_at='3'", + [] + ) + .is_err(), + "removed Worker cannot receive new authority" + ); + } + + #[tokio::test] + async fn close_retains_responsibility_without_occupying_new_work_and_reopen_requires_new_identity() + { + use ticket::TicketBackend; + 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(); + 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','coder','Coder','normal','1','1');")?; + Ok(()) + }).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap(); + let first = backend.create(ticket::NewTicket::new("First")).unwrap(); + let second = backend.create(ticket::NewTicket::new("Second")).unwrap(); + let assignment = TicketCoderAssignmentRecord { + workspace_id: "w".into(), + ticket_id: first.id.clone(), + assignment_id: "a1".into(), + worker: RuntimeWorkerRef::new("r", "coder"), + assigned_by: "actor".into(), + assigned_at: "1".into(), + }; + store + .set_current_ticket_coder_assignment(&assignment, None, "e1", "op1", false) + .unwrap(); + let next = TicketCoderAssignmentRecord { + ticket_id: second.id.clone(), + assignment_id: "a2".into(), + ..assignment.clone() + }; + assert!( + store + .set_current_ticket_coder_assignment(&next, None, "e2", "op2", false) + .is_err() + ); + backend + .close( + first.id.clone().into(), + ticket::MarkdownText::new("Closed without integration proof"), + ) + .unwrap(); + assert_eq!( + store + .get_current_ticket_coder_assignment("w", &first.id) + .unwrap(), + Some(assignment.clone()) + ); + assert!( + store + .get_active_ticket_worker_assignment("w", &first.id) + .unwrap() + .is_none() + ); + store + .set_current_ticket_coder_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(); + assert!( + store + .get_active_ticket_worker_assignment("w", &first.id) + .unwrap() + .is_none() + ); + // Replay of the original operation reports its identity but does not revive work. + store + .set_current_ticket_coder_assignment(&assignment, None, "e1", "op1", false) + .unwrap(); + assert!( + store + .get_active_ticket_worker_assignment("w", &first.id) + .unwrap() + .is_none() + ); + let restarted = TicketCoderAssignmentRecord { + assignment_id: "a3".into(), + ..assignment.clone() + }; + assert!( + store + .set_current_ticket_coder_assignment(&restarted, None, "e3", "op3", false) + .is_err(), + "another Ticket still occupies the Worker" + ); + backend + .close(second.id.clone().into(), ticket::MarkdownText::new("Ended")) + .unwrap(); + store + .set_current_ticket_coder_assignment(&restarted, None, "e3", "op3", false) + .unwrap(); + assert_eq!( + store + .get_active_ticket_worker_assignment("w", &first.id) + .unwrap(), + Some(restarted) + ); + let events = store + .list_ticket_coder_assignment_events("w", &first.id, 10) + .unwrap(); + assert_eq!(events[0].previous_assignment_id.as_deref(), Some("a1")); + assert_eq!(events[0].action, "reassigned"); + assert!( + store + .delete_worker_registry("w", &assignment.worker) + .is_err() + ); + } + #[tokio::test] async fn ticket_worker_assignment_replaces_current_and_preserves_audit_history() { let dir = tempfile::tempdir().unwrap(); @@ -21230,7 +21741,7 @@ INSERT INTO worker_registry ( operation_key: "invalid-key".to_string(), request_fingerprint: "sha256:invalid-key".to_string(), workspace: workspace.clone(), - repository, + repository: Some(repository), }, &signing_identity, "identity-store-test", @@ -21269,7 +21780,7 @@ INSERT INTO worker_registry ( operation_key: "create-workspace".to_string(), request_fingerprint: "sha256:create-workspace".to_string(), workspace, - repository: valid_repository, + repository: Some(valid_repository), }; store .reserve_workspace_signing_identity_provisioning( @@ -21296,7 +21807,7 @@ INSERT INTO worker_registry ( ); let mut duplicate = first; duplicate.operation_key = "duplicate-workspace".to_string(); - duplicate.repository.repository_id = Uuid::now_v7().to_string(); + duplicate.repository.as_mut().unwrap().repository_id = Uuid::now_v7().to_string(); let error = store .create_workspace_bootstrap(&duplicate, &signing_identity, "identity-store-test") .unwrap_err() diff --git a/crates/workspace-server/src/ticket_item_checker.rs b/crates/workspace-server/src/ticket_item_checker.rs index af592f9cc..dd45980d0 100644 --- a/crates/workspace-server/src/ticket_item_checker.rs +++ b/crates/workspace-server/src/ticket_item_checker.rs @@ -89,14 +89,7 @@ impl TicketItemCheckFindingCategory { } pub fn item_revision(ticket: &ticket::Ticket) -> String { - ticket - .events - .iter() - .rev() - .find(|event| matches!(event.kind.as_str(), "create" | "item_edit")) - .and_then(|event| event.attributes.get("event_id").cloned()) - .or_else(|| ticket.meta.updated_at.clone()) - .unwrap_or_else(|| format!("{}:0", ticket.meta.id)) + ticket::ticket_item_revision(ticket) } pub fn request( diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index 5044d8045..0208714fe 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -356,10 +356,10 @@ impl SqliteWorkspaceStore { params![operation.workspace_id, operation.working_directory_id], |row| row.get(0), )?; - let current_assignment: bool = conn.query_row( + let unfinished_work: bool = conn.query_row( r#"SELECT EXISTS( SELECT 1 FROM worker_workdir_links AS link - JOIN ticket_current_worker_assignments AS current + JOIN ticket_active_worker_assignments AS current ON current.workspace_id=link.workspace_id JOIN ticket_worker_assignments AS assignment ON assignment.workspace_id=current.workspace_id @@ -404,10 +404,10 @@ impl SqliteWorkspaceStore { detail: "Workdir has a pending Worker attachment reservation", }); } - if current_assignment { + if unfinished_work { guards.push(WorkdirRemovalGuard { - category: "current_assignment", - detail: "Workdir is bound to a Worker with a current Ticket assignment", + category: "unfinished_work", + detail: "Workdir is bound to a Worker with unfinished Ticket work", }); } if retention_hold { @@ -1196,6 +1196,156 @@ 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 + .with_conn(|conn| { + conn.execute( + "INSERT INTO worker_registry ( + workspace_id, runtime_id, worker_id, display_name, retention_state, + created_at, updated_at + ) VALUES ('workspace-a', 'runtime-a', 'worker-a', 'Worker A', 'pinned', '1', '1')", + [], + )?; + conn.execute( + "INSERT INTO worker_workdir_links ( + workspace_id, runtime_id, worker_id, workdir_id, alias, capabilities, linked_at + ) VALUES ('workspace-a', 'runtime-a', 'worker-a', 'workdir-a', 'main', 'read_write', '1')", + [], + )?; + conn.execute( + "INSERT INTO typed_tickets ( + workspace_id, ticket_id, slug, title, status, kind, priority, body, + workflow_state, workflow_state_explicit + ) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', + 'task', 'normal', '', 'inprogress', 1)", + [], + )?; + conn.execute( + "INSERT INTO ticket_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, + assigned_by, assigned_at + ) VALUES ('workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', 'worker-a', 'test', '1')", + [], + )?; + conn.execute( + "INSERT INTO ticket_current_worker_assignments ( + workspace_id, ticket_id, assignment_id, runtime_id, worker_id, updated_at + ) VALUES ('workspace-a', 'ticket-a', 'assignment-a', 'runtime-a', 'worker-a', '1')", + [], + )?; + 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 + 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' + 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() + ); + store + .with_conn(|conn| { + conn.execute( + "UPDATE worker_registry SET retention_state='normal' + WHERE workspace_id='workspace-a' AND runtime_id='runtime-a' AND worker_id='worker-a'", + [], + )?; + 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' + 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 + WHERE workspace_id='workspace-a' AND assignment_id='assignment-a'", + [], + |row| row.get(0), + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!(retained, 1); + } + } + #[tokio::test] async fn retained_completion_keeps_registry_and_is_auditable() { let (store, workdir) = seeded_store().await; diff --git a/crates/workspace-server/src/workspace_catalog.rs b/crates/workspace-server/src/workspace_catalog.rs index d9fce280e..199387849 100644 --- a/crates/workspace-server/src/workspace_catalog.rs +++ b/crates/workspace-server/src/workspace_catalog.rs @@ -22,7 +22,7 @@ const MAX_OPERATION_KEY_BYTES: usize = 200; #[derive(Debug, Clone, PartialEq, Eq)] pub struct WorkspaceCreateResult { pub workspace: WorkspaceRecord, - pub repository: RepositoryRecord, + pub repository: Option, pub config_revision: u64, pub request_fingerprint: String, pub replayed: bool, @@ -94,19 +94,23 @@ impl WorkspaceCatalogService { )?; let display_name = normalize_required("display_name", request.display_name, MAX_DISPLAY_NAME_BYTES)?; - let repository_source = validate_repository_source(&request.repository.uri)?; - let repository_uri = repository_source.uri.clone(); - server_api::validate_repository_key(&request.repository.repository_key) - .map_err(|error| Error::InvalidInput(format!("invalid Repository key: {error}")))?; - let repository_key = request.repository.repository_key.clone(); - let default_ref = request + let repository_intent = request .repository - .default_ref - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or("HEAD") - .to_string(); + .map(|repository| { + let source = validate_repository_source(&repository.uri)?; + server_api::validate_repository_key(&repository.repository_key).map_err( + |error| Error::InvalidInput(format!("invalid Repository key: {error}")), + )?; + let default_ref = repository + .default_ref + .as_deref() + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("HEAD") + .to_string(); + Ok::<_, Error>((repository.repository_key, source, default_ref)) + }) + .transpose()?; let requested_workspace_id = requested_workspace_id .map(|value| { Uuid::parse_str(value.trim()) @@ -121,9 +125,9 @@ impl WorkspaceCatalogService { requested_workspace_id.as_deref(), &display_name, Some(&owner_account_id), - &repository_key, - &repository_uri, - &default_ref, + repository_intent.as_ref().map(|(key, source, selector)| { + (key.as_str(), source.uri.as_str(), selector.as_str()) + }), ); let now = Utc::now().to_rfc3339_opts(SecondsFormat::Millis, true); let (signing_identity, identity_provisioning_operation_key) = @@ -146,21 +150,23 @@ impl WorkspaceCatalogService { created_at: now.clone(), updated_at: now.clone(), }, - repository: RepositoryRecord { - workspace_id, - repository_id: Uuid::now_v7().to_string(), - repository_key: repository_key.clone(), - kind: "git".to_string(), - provider: Some("git".to_string()), - source: repository_source.clone(), - default_ref: Some(default_ref), - source_revision: 1, - source_fingerprint: repository_source_fingerprint(&repository_source), - observed_status: RepositoryObservedStatus::Unverified, - observed_at: None, - created_at: now.clone(), - updated_at: now, - }, + repository: repository_intent.map( + |(repository_key, repository_source, default_ref)| RepositoryRecord { + workspace_id, + repository_id: Uuid::now_v7().to_string(), + repository_key: repository_key.clone(), + kind: "git".to_string(), + provider: Some("git".to_string()), + source: repository_source.clone(), + default_ref: Some(default_ref), + source_revision: 1, + source_fingerprint: repository_source_fingerprint(&repository_source), + observed_status: RepositoryObservedStatus::Unverified, + observed_at: None, + created_at: now.clone(), + updated_at: now, + }, + ), }, &signing_identity, &identity_provisioning_operation_key, @@ -193,20 +199,19 @@ fn workspace_create_fingerprint( requested_workspace_id: Option<&str>, display_name: &str, owner_account_id: Option<&str>, - repository_key: &str, - repository_uri: &str, - default_ref: &str, + repository: Option<(&str, &str, &str)>, ) -> String { let payload = serde_json::json!({ "requested_workspace_id": requested_workspace_id, "display_name": display_name, "owner_account_id": owner_account_id, - "repository": { - "repository_key": repository_key, - "uri": repository_uri, - "default_ref": default_ref, + // Preserve repository-present fingerprint bytes for persisted create receipts. + "repository": repository.map(|(key, uri, selector)| serde_json::json!({ + "repository_key": key, + "uri": uri, + "default_ref": selector, "kind": "git", - } + })) }); let mut hasher = Sha256::new(); hasher.update(serde_json::to_vec(&payload).expect("workspace fingerprint serializes")); @@ -294,11 +299,11 @@ mod tests { let request = WorkspaceCreateRequest { operation_key: "request-1".to_string(), display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository.path().display().to_string(), repository_key: "platform".to_string(), default_ref: None, - }, + }), }; let owner_account_id = owner_account(store.as_ref()); @@ -354,11 +359,11 @@ mod tests { let request = WorkspaceCreateRequest { operation_key: operation_key.to_string(), display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository.path().display().to_string(), repository_key: repository_key.to_string(), default_ref: None, - }, + }), }; let error = service @@ -373,36 +378,37 @@ mod tests { #[tokio::test] async fn create_recovers_same_reserved_identity_after_material_write_failure() { - let temp = tempfile::tempdir().unwrap(); - let database_path = temp.path().join("server.db"); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); - let owner_account_id = owner_account(store.as_ref()); - let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); - let service = WorkspaceCatalogService::new( - store.clone(), - Arc::new(FailFirstMaterialWrite { - inner: materials.clone(), - fail: AtomicBool::new(true), - }), - ); - let repository = git_repository(); - let request = WorkspaceCreateRequest { - operation_key: "material-failure".to_string(), - display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { - uri: repository.path().display().to_string(), - repository_key: "main".to_string(), - default_ref: None, - }, - }; + for with_repository in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("server.db"); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let owner_account_id = owner_account(store.as_ref()); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new(FailFirstMaterialWrite { + inner: materials.clone(), + fail: AtomicBool::new(true), + }), + ); + let repository = git_repository(); + let request = WorkspaceCreateRequest { + operation_key: "material-failure".to_string(), + display_name: "Workspace A".to_string(), + repository: with_repository.then(|| InitialRepositoryIntent { + uri: repository.path().display().to_string(), + repository_key: "main".to_string(), + default_ref: None, + }), + }; - assert!( - service - .create(request.clone(), owner_account_id.clone()) - .is_err() - ); - assert!(store.list_workspaces().unwrap().is_empty()); - let reserved_key = store + assert!( + service + .create(request.clone(), owner_account_id.clone()) + .is_err() + ); + assert!(store.list_workspaces().unwrap().is_empty()); + let reserved_key = store .with_conn(|conn| { conn.query_row( "SELECT key_id FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:material-failure' AND state = 'pending'", @@ -413,55 +419,57 @@ mod tests { }) .unwrap(); - drop(service); - drop(store); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); - let restarted = WorkspaceCatalogService::new(store.clone(), materials); - let created = restarted.create(request, owner_account_id).unwrap(); - let identity = store - .get_workspace_signing_identity(&created.workspace.workspace_id) - .unwrap() - .unwrap(); - assert_eq!(identity.key_id, reserved_key); - assert_eq!(store.list_workspaces().unwrap().len(), 1); + drop(service); + drop(store); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let restarted = WorkspaceCatalogService::new(store.clone(), materials); + let created = restarted.create(request, owner_account_id).unwrap(); + let identity = store + .get_workspace_signing_identity(&created.workspace.workspace_id) + .unwrap() + .unwrap(); + assert_eq!(identity.key_id, reserved_key); + assert_eq!(store.list_workspaces().unwrap().len(), 1); + } } #[tokio::test] async fn create_rolls_back_db_state_and_recovers_published_identity_after_restart() { - let temp = tempfile::tempdir().unwrap(); - let database_path = temp.path().join("server.db"); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); - let owner_account_id = owner_account(store.as_ref()); - let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); - let service = WorkspaceCatalogService::new(store.clone(), materials.clone()); - let repository = git_repository(); - let request = WorkspaceCreateRequest { - operation_key: "db-failure".to_string(), - display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { - uri: repository.path().display().to_string(), - repository_key: "main".to_string(), - default_ref: None, - }, - }; - store - .with_conn(|conn| { - conn.execute_batch( - r#"CREATE TRIGGER fail_workspace_create_identity_audit + for with_repository in [false, true] { + let temp = tempfile::tempdir().unwrap(); + let database_path = temp.path().join("server.db"); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let owner_account_id = owner_account(store.as_ref()); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let service = WorkspaceCatalogService::new(store.clone(), materials.clone()); + let repository = git_repository(); + let request = WorkspaceCreateRequest { + operation_key: "db-failure".to_string(), + display_name: "Workspace A".to_string(), + repository: with_repository.then(|| InitialRepositoryIntent { + uri: repository.path().display().to_string(), + repository_key: "main".to_string(), + default_ref: None, + }), + }; + store + .with_conn(|conn| { + conn.execute_batch( + r#"CREATE TRIGGER fail_workspace_create_identity_audit BEFORE INSERT ON workspace_signing_identity_audit BEGIN SELECT RAISE(ABORT, 'injected audit failure'); END;"#, - )?; - Ok(()) - }) - .unwrap(); + )?; + Ok(()) + }) + .unwrap(); - assert!( - service - .create(request.clone(), owner_account_id.clone()) - .is_err() - ); - assert!(store.list_workspaces().unwrap().is_empty()); - let (reserved_key, material_ref) = store + assert!( + service + .create(request.clone(), owner_account_id.clone()) + .is_err() + ); + assert!(store.list_workspaces().unwrap().is_empty()); + let (reserved_key, material_ref) = store .with_conn(|conn| { conn.query_row( "SELECT key_id, private_material_ref FROM workspace_signing_identity_provisioning_operations WHERE operation_key = 'workspace-create:db-failure' AND state = 'pending'", @@ -471,25 +479,26 @@ mod tests { .map_err(Error::from) }) .unwrap(); - assert!(materials.load(&material_ref).unwrap().is_some()); - store - .with_conn(|conn| { - conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?; - Ok(()) - }) - .unwrap(); + assert!(materials.load(&material_ref).unwrap().is_some()); + store + .with_conn(|conn| { + conn.execute_batch("DROP TRIGGER fail_workspace_create_identity_audit;")?; + Ok(()) + }) + .unwrap(); - drop(service); - drop(store); - let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); - let restarted = WorkspaceCatalogService::new(store.clone(), materials); - let created = restarted.create(request, owner_account_id).unwrap(); - let identity = store - .get_workspace_signing_identity(&created.workspace.workspace_id) - .unwrap() - .unwrap(); - assert_eq!(identity.key_id, reserved_key); - assert_eq!(identity.state, "active"); + drop(service); + drop(store); + let store = Arc::new(SqliteWorkspaceStore::open(&database_path).unwrap()); + let restarted = WorkspaceCatalogService::new(store.clone(), materials); + let created = restarted.create(request, owner_account_id).unwrap(); + let identity = store + .get_workspace_signing_identity(&created.workspace.workspace_id) + .unwrap() + .unwrap(); + assert_eq!(identity.key_id, reserved_key); + assert_eq!(identity.state, "active"); + } } #[tokio::test] @@ -506,11 +515,11 @@ mod tests { let mut request = WorkspaceCreateRequest { operation_key: "request-1".to_string(), display_name: "Workspace A".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository.path().display().to_string(), repository_key: "main".to_string(), default_ref: None, - }, + }), }; service .create(request.clone(), owner_account_id.clone()) @@ -524,6 +533,199 @@ mod tests { assert!(error.contains("different input"), "{error}"); } + #[test] + fn repository_free_create_initializes_authority_and_concurrent_retries_converge() { + let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let owner = owner_account(store.as_ref()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new(InMemoryWorkspaceSigningMaterialStore::default()), + ); + let request = WorkspaceCreateRequest { + operation_key: "empty-concurrent".to_string(), + display_name: "No repositories".to_string(), + repository: None, + }; + let barrier = Arc::new(std::sync::Barrier::new(4)); + let handles = (0..4) + .map(|_| { + let (service, request, owner, barrier) = ( + service.clone(), + request.clone(), + owner.clone(), + barrier.clone(), + ); + std::thread::spawn(move || { + barrier.wait(); + service.create(request, owner).unwrap() + }) + }) + .collect::>(); + let results = handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .collect::>(); + let id = &results[0].workspace.workspace_id; + assert_eq!(results.iter().filter(|result| !result.replayed).count(), 1); + assert!( + results + .iter() + .all(|result| &result.workspace.workspace_id == id && result.repository.is_none()) + ); + assert_eq!(store.list_workspaces().unwrap().len(), 1); + assert!(store.list_repositories(id).unwrap().is_empty()); + assert!(store.load_workspace_config(id).unwrap().is_some()); + assert_eq!( + store + .get_workspace_signing_identity(id) + .unwrap() + .unwrap() + .state, + "active" + ); + let counters = store.with_conn(|conn| { + let mut query = conn.prepare("SELECT resource_kind, next_sequence FROM workspace_resource_key_counters WHERE workspace_id = ?1 ORDER BY resource_kind")?; + Ok(query.query_map([id], |row| Ok((row.get::<_, String>(0)?, row.get::<_, i64>(1)?)))?.collect::, _>>()?) + }).unwrap(); + assert_eq!( + counters, + vec![ + ("objective".into(), 1), + ("ticket".into(), 1), + ("worker".into(), 1) + ] + ); + let mut different = request.clone(); + different.repository = Some(InitialRepositoryIntent { + repository_key: "main".into(), + uri: "/runtime/repo".into(), + default_ref: None, + }); + assert!( + service + .create(different, owner.clone()) + .unwrap_err() + .to_string() + .contains("different input") + ); + assert!(service.create(request, "missing-owner".into()).is_err()); + assert!(store.list_repositories(id).unwrap().is_empty()); + } + + #[test] + fn replay_reads_current_repository_at_initial_key_and_never_recreates_it() { + let store = Arc::new(SqliteWorkspaceStore::in_memory().unwrap()); + let owner = owner_account(store.as_ref()); + let service = WorkspaceCatalogService::new( + store.clone(), + Arc::new(InMemoryWorkspaceSigningMaterialStore::default()), + ); + let request = WorkspaceCreateRequest { + operation_key: "current-repository".into(), + display_name: "Workspace".into(), + repository: Some(InitialRepositoryIntent { + repository_key: "main".into(), + uri: "/runtime/original".into(), + default_ref: None, + }), + }; + let created = service.create(request.clone(), owner.clone()).unwrap(); + let mut repository = created.repository.unwrap(); + repository.source = validate_repository_source("https://example.test/changed.git").unwrap(); + repository.source_revision += 1; + repository.source_fingerprint = repository_source_fingerprint(&repository.source); + store.with_conn(|conn| { + conn.execute("UPDATE repositories SET source_kind = ?1, source_uri = ?2, source_revision = ?3, source_fingerprint = ?4 WHERE workspace_id = ?5 AND repository_key = 'main'", + rusqlite::params![repository.source.kind.as_str(), repository.source.uri, repository.source_revision, repository.source_fingerprint, repository.workspace_id])?; + Ok(()) + }).unwrap(); + assert_eq!( + service + .create(request.clone(), owner.clone()) + .unwrap() + .repository, + Some(repository) + ); + store + .with_conn(|conn| { + conn.execute( + "DELETE FROM repositories WHERE workspace_id = ?1", + [&created.workspace.workspace_id], + )?; + Ok(()) + }) + .unwrap(); + let replay = service.create(request, owner).unwrap(); + assert!(replay.replayed); + assert!(replay.repository.is_none()); + assert!( + store + .list_repositories(&replay.workspace.workspace_id) + .unwrap() + .is_empty() + ); + } + + #[test] + fn persisted_repository_free_receipt_replays_after_catalog_reopen() { + let temp = tempfile::tempdir().unwrap(); + let database = temp.path().join("server.db"); + let materials = Arc::new(InMemoryWorkspaceSigningMaterialStore::default()); + let request = WorkspaceCreateRequest { + operation_key: "persist-empty".into(), + display_name: "Workspace".into(), + repository: None, + }; + let (owner, created) = { + let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap()); + let owner = owner_account(store.as_ref()); + let service = WorkspaceCatalogService::new(store, materials.clone()); + let created = service.create(request.clone(), owner.clone()).unwrap(); + (owner, created) + }; + let store = Arc::new(SqliteWorkspaceStore::open(&database).unwrap()); + let reopened = WorkspaceCatalogService::new(store.clone(), materials); + let replay = reopened.create(request, owner).unwrap(); + assert_eq!(replay.workspace, created.workspace); + assert!(replay.replayed); + assert!(replay.repository.is_none()); + assert!( + store + .list_repositories(&replay.workspace.workspace_id) + .unwrap() + .is_empty() + ); + } + + #[test] + fn repository_present_fingerprint_preserves_saved_receipt_contract() { + let legacy_payload = serde_json::json!({ + "requested_workspace_id": null, "display_name": "Workspace", "owner_account_id": "owner", + "repository": {"repository_key": "main", "uri": "/runtime/repo", "default_ref": "HEAD", "kind": "git"} + }); + let legacy_digest = Sha256::digest(serde_json::to_vec(&legacy_payload).unwrap()); + let legacy = format!( + "sha256:{}", + legacy_digest + .iter() + .map(|byte| format!("{byte:02x}")) + .collect::() + ); + assert_eq!( + workspace_create_fingerprint( + None, + "Workspace", + Some("owner"), + Some(("main", "/runtime/repo", "HEAD")) + ), + legacy + ); + assert_ne!( + workspace_create_fingerprint(None, "Workspace", Some("owner"), None), + legacy + ); + } + #[test] fn repository_intent_accepts_unavailable_local_sources_without_server_io() { let remote = validate_repository_source("https://example.test/repo.git").unwrap(); @@ -564,11 +766,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "organization-owner-create".to_string(), display_name: "Organization Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository.path().display().to_string(), repository_key: "main".to_string(), default_ref: None, - }, + }), }, "organization-owner".to_string(), ) @@ -605,11 +807,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "owner-a-create".to_string(), display_name: "Owner A Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository_a.path().display().to_string(), repository_key: "main".to_string(), default_ref: None, - }, + }), }, owner_a.clone(), ) @@ -619,11 +821,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "owner-b-create".to_string(), display_name: "Owner B Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: repository_b.path().display().to_string(), repository_key: "main".to_string(), default_ref: None, - }, + }), }, owner_b.clone(), ) @@ -660,11 +862,11 @@ mod tests { WorkspaceCreateRequest { operation_key: "remote-create".to_string(), display_name: "Remote Workspace".to_string(), - repository: InitialRepositoryIntent { + repository: Some(InitialRepositoryIntent { uri: "ssh://git@example.test/org/repository.git".to_string(), repository_key: "remote".to_string(), default_ref: Some("main".to_string()), - }, + }), }, owner_account_id, ) @@ -673,7 +875,7 @@ mod tests { let persisted = store .get_repository( &result.workspace.workspace_id, - &result.repository.repository_id, + &result.repository.as_ref().unwrap().repository_id, ) .unwrap() .unwrap(); diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index b5d997dcf..360b239d2 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -45,6 +45,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "repository_ssh_host_trusts", "server_secret_versions", "ticket_assignment_operations", + "ticket_assignment_work_releases", "ticket_assignment_ticket_tombstones", "ticket_assignment_worker_tombstones", "ticket_current_worker_assignments", @@ -686,10 +687,10 @@ fn workspace_database_blockers( for (sql, kind, resource_kind, message) in [ ( - "SELECT COUNT(*) FROM ticket_current_worker_assignments WHERE workspace_id = ?1", + "SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE workspace_id = ?1", WorkspaceDeletionBlockerKind::WorkerRemovalBlocked, "ticket", - "Remove current Ticket assignments before deleting the Workspace.", + "End unfinished Ticket work before deleting the Workspace.", ), ( "SELECT COUNT(*) diff --git a/crates/yoi/README.md b/crates/yoi/README.md index 397c6ee6e..3bb4b210b 100644 --- a/crates/yoi/README.md +++ b/crates/yoi/README.md @@ -24,6 +24,39 @@ Does not own: Keeping product CLI ownership here prevents lower crates from depending on the binary name or parsing top-level arguments. Runtime launch should use typed command boundaries, not shell-command strings. +## Workspace creation and selection + +`yoi init` remains Git-oriented and requires an explicit initial repository key: + +```sh +yoi --backend https://backend.example init --display-name Development --repository-key platform +``` + +For a Workspace with no initial repository, use the same Backend creation API explicitly: + +```sh +yoi --backend https://backend.example workspace create --display-name Research +``` + +Both commands record Workspace-to-Backend routing in the global client configuration, +not repository-local identity files. Repository-free creation prints the Workspace id; +select it explicitly from any directory, without Git discovery: + +```sh +yoi --workspace-id +yoi --workspace-id workers +yoi --workspace-id resume +yoi --workspace-id panel +``` + +`--workspace-id` selects Backend even when `default_connection` is `local`, and conflicts +with `--local`. An explicit `--backend ` overrides configured routing. Without an +explicit Workspace id, Backend commands retain Git-based Workspace discovery. + +Normal Backend startup launches the existing workdir-free Worker form. Repository and +workdir attachment selection are not prerequisites imposed by this CLI; Runtime +availability and workdir requirements remain Backend/TUI responsibilities. + ## See also - [`../../docs/design/overview.md`](../../docs/design/overview.md) diff --git a/crates/yoi/src/cli_connection.rs b/crates/yoi/src/cli_connection.rs index 618b8cf39..488969646 100644 --- a/crates/yoi/src/cli_connection.rs +++ b/crates/yoi/src/cli_connection.rs @@ -250,10 +250,15 @@ pub(crate) fn resolve_connection_aware_cli_connection), @@ -123,10 +126,7 @@ async fn run(mode: Mode) -> ExitCode { }, Mode::Init(options) => match run_init(options).await { Ok(workspace) => { - println!( - "Initialized Workspace '{}' for repository '{}'", - workspace.workspace.display_name, workspace.repository.repository_key - ); + println!("{}", workspace_creation_message(&workspace)); ExitCode::SUCCESS } Err(error) => { @@ -134,6 +134,20 @@ async fn run(mode: Mode) -> ExitCode { ExitCode::FAILURE } }, + Mode::WorkspaceHelp => { + print!("{WORKSPACE_HELP}"); + ExitCode::SUCCESS + } + Mode::WorkspaceCreate(options) => match run_workspace_create(options).await { + Ok(workspace) => { + println!("{}", workspace_creation_message(&workspace)); + ExitCode::SUCCESS + } + Err(error) => { + eprintln!("yoi workspace create: {error}"); + ExitCode::FAILURE + } + }, Mode::WorkdirHelp => { print_workdir_help(); ExitCode::SUCCESS @@ -278,7 +292,7 @@ fn resolve_tui_target( command, true, None, - None, + selection.workspace_id.as_deref(), ); } @@ -521,6 +535,9 @@ fn parse_args_slice_with_connection_resolver( .map_err(|error| ParseError(error.to_string()))?; return Ok(Mode::Ticket { cli, target }); } + "workspace" => { + return parse_workspace_args(&args[1..], &target_selection); + } "init" => { if target_selection.explicit_local { return Err(ParseError( @@ -807,6 +824,68 @@ fn parse_workdir_args( })) } +const WORKSPACE_HELP: &str = "yoi workspace\n\nUsage:\n yoi [--backend ] workspace create --display-name \n\nCreate a Workspace without an initial repository; no Git checkout is needed.\nUse `yoi init` to create a Workspace with the current Git repository.\nSelect the created Workspace with `yoi --workspace-id `; this does not require Git.\n\nOptions:\n --display-name Workspace display name (required)\n -h, --help Print help\n"; + +fn parse_workspace_args(args: &[String], selection: &TargetSelection) -> Result { + let Some((subcommand, args)) = args.split_first() else { + return Ok(Mode::WorkspaceHelp); + }; + if matches!(subcommand.as_str(), "--help" | "-h") { + return Ok(Mode::WorkspaceHelp); + } + if subcommand != "create" { + return Err(ParseError(format!( + "unknown yoi workspace subcommand `{subcommand}`" + ))); + } + if args + .iter() + .any(|arg| matches!(arg.as_str(), "--help" | "-h")) + { + return Ok(Mode::WorkspaceHelp); + } + if selection.explicit_local { + return Err(ParseError( + "yoi workspace create requires a Backend target and cannot use --local".to_string(), + )); + } + if selection.workspace_id.is_some() { + return Err(ParseError( + "yoi workspace create creates a Workspace and does not accept --workspace-id" + .to_string(), + )); + } + let mut display_name = None; + let mut index = 0; + while index < args.len() { + match args[index].as_str() { + "--display-name" => { + if display_name.is_some() { + return Err(ParseError( + "--display-name may only be provided once".to_string(), + )); + } + display_name = + Some(required_option_value(args, index, "--display-name")?.to_string()); + index += 2; + } + unknown => { + return Err(ParseError(format!( + "unknown yoi workspace create option `{unknown}`" + ))); + } + } + } + let display_name = display_name.ok_or_else(|| { + ParseError("yoi workspace create requires --display-name NAME".to_string()) + })?; + Ok(Mode::WorkspaceCreate(WorkspaceCreateOptions { + backend_url: resolve_backend_url(selection.backend_url.clone(), None)?, + display_name, + repository: None, + })) +} + fn parse_init_args( args: &[String], explicit_backend_url: Option, @@ -1989,6 +2068,7 @@ Usage: yoi [TARGET] workers [-r|--stopped] [--runtime-id ] yoi [TARGET] resume [--all] [--runtime-id ] yoi --backend [--workspace-id ] panel + yoi [--backend ] workspace create --display-name yoi [--backend ] init --display-name --repository-key [--repository ] [--default-ref ] yoi [--backend ] login [--no-wait] yoi [--backend ] workdir share [--workspace-id ] [--permission ] [--read-only|--read-write] [--ttl ] [--display-name ] [--non-interactive] @@ -1997,9 +2077,11 @@ Usage: Target selection: --local Use the client-owned one-process Standalone host --backend Use a Workspace Backend explicitly - --workspace-id Scope Backend routes to a Workspace id + --workspace-id Select a Backend Workspace explicitly; no Git checkout needed If no target is explicit, connection-aware commands use the merged client config. + Explicit --workspace-id selects Backend even when the default connection is local. + Without --workspace-id, Backend selection matches the current Git repository. `default_connection = "local"` selects Standalone; it does not enable a filesystem Ticket, Objective, Worker catalog, PID, socket, or subprocess authority. @@ -2017,6 +2099,7 @@ Console options: --worker-id Backend Worker id; requires --runtime-id Host commands: + yoi workspace create Create a Backend Workspace without an initial repository. yoi init Register the current Git repository as a new Backend Workspace. yoi workdir share Interactively share a client-hosted External Workdir. keys Manage local model/API keys @@ -2058,7 +2141,7 @@ Authority: Options: --backend Use this Workspace Backend - --workspace-id Scope Backend routes to a Workspace id + --workspace-id Select a Backend Workspace explicitly; no Git checkout needed --workspace Match this Git repository against Server DB Repository records -r, --stopped List stopped Backend Workers --runtime-id Restrict the Backend Worker picker to a Runtime id @@ -2077,7 +2160,7 @@ Usage: Target options: --local Restore from the client-owned Standalone Worker store --backend Restore a stopped Backend Workspace Worker - --workspace-id Scope Backend routes to a Workspace id + --workspace-id Select a Backend Workspace explicitly; no Git checkout needed Options: --workspace Scope Standalone Workers to this cwd identity (defaults to cwd) @@ -2461,7 +2544,8 @@ backend = "shared" ) ); assert!(!TOP_LEVEL_HELP.contains("Build/check/list/show plugins")); - assert!(!TOP_LEVEL_HELP.contains("yoi workspace")); + assert!(TOP_LEVEL_HELP.contains("yoi workspace create")); + assert!(!TOP_LEVEL_HELP.contains("yoi workspace serve")); assert!(!TOP_LEVEL_HELP.contains("yoi server")); assert!(!TOP_LEVEL_HELP.contains("TARGET_OPTIONS")); } @@ -3174,6 +3258,232 @@ backend = "shared" assert!(!error.contains("stale-local-workspace")); } + #[test] + fn workspace_create_parsing_requires_no_repository_inputs() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://unused.example", + }; + let args = [ + "--backend", + "http://backend.example", + "workspace", + "create", + "--display-name", + "Research", + ] + .map(String::from); + let Mode::WorkspaceCreate(options) = + parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected workspace creation mode"); + }; + assert_eq!(options.backend_url, "http://backend.example"); + assert_eq!(options.display_name, "Research"); + assert!(options.repository.is_none()); + } + + #[test] + fn workspace_create_rejects_repository_options_and_invalid_creation_selectors() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://unused.example", + }; + for (prefix, suffix, expected) in [ + ( + vec!["--local"], + vec!["--display-name", "Research"], + "cannot use --local", + ), + ( + vec!["--workspace-id", "existing"], + vec!["--display-name", "Research"], + "does not accept --workspace-id", + ), + (vec![], vec![], "requires --display-name NAME"), + ( + vec![], + vec!["--display-name"], + "--display-name requires a value", + ), + ( + vec![], + vec!["--display-name", "A", "--display-name", "B"], + "may only be provided once", + ), + ( + vec![], + vec!["--display-name", "A", "--repository", "/tmp"], + "unknown yoi workspace create option `--repository`", + ), + ( + vec![], + vec!["--display-name", "A", "--repository-key", "main"], + "unknown yoi workspace create option `--repository-key`", + ), + ( + vec![], + vec!["--display-name", "A", "--default-ref", "main"], + "unknown yoi workspace create option `--default-ref`", + ), + ] { + let args = [prefix, vec!["workspace", "create"], suffix] + .concat() + .into_iter() + .map(String::from) + .collect::>(); + let error = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap_err(); + assert!(error.to_string().contains(expected), "{args:?}: {error}"); + } + } + + #[test] + fn workspace_help_documents_repo_free_creation_without_resolving_backend() { + for args in [ + vec!["workspace"], + vec!["workspace", "--help"], + vec!["workspace", "create", "--help"], + ] { + assert!(matches!( + parse_args_from(args).unwrap(), + Mode::WorkspaceHelp + )); + } + assert!(TOP_LEVEL_HELP.contains("workspace create --display-name ")); + assert!(WORKSPACE_HELP.contains("without an initial repository")); + assert!(WORKSPACE_HELP.contains("--workspace-id ")); + } + + #[test] + fn explicit_workspace_selects_backend_from_non_git_directory_even_with_local_default() { + let directory = tempfile::tempdir().unwrap(); + let resolver = FixedCliConnectionResolver { + backend_url: "http://configured-backend.example", + }; + // This resolver defaults to Standalone and rejects repository-based selection. + for command in [None, Some("workers"), Some("resume"), Some("panel")] { + let mut args = vec!["--workspace-id".to_string(), "repo-free".to_string()]; + if let Some(command) = command { + args.push(command.to_string()); + } + args.extend([ + "--workspace".to_string(), + directory.path().display().to_string(), + ]); + let Mode::Tui { + target, + mode, + workspace_root, + } = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected TUI mode for {args:?}"); + }; + assert_eq!(workspace_root, directory.path()); + assert_eq!( + target.resolve().unwrap(), + client::ResolvedTarget::Backend { + base_url: "http://configured-backend.example".to_string(), + workspace_id: "repo-free".to_string(), + } + ); + match command { + None => assert!(matches!(mode, LaunchMode::BackendSpawn)), + Some("workers") => assert!(matches!( + mode, + LaunchMode::Workers { + intent: BackendWorkerPickerIntent::Attach { .. }, + .. + } + )), + Some("resume") => assert!(matches!( + mode, + LaunchMode::Workers { + intent: BackendWorkerPickerIntent::Resume, + .. + } + )), + Some("panel") => assert!(matches!(mode, LaunchMode::Panel)), + _ => unreachable!(), + } + } + assert!(!directory.path().join(".git").exists()); + assert!(!directory.path().join(".yoi").exists()); + } + + #[test] + fn explicit_workspace_attaches_to_backend_worker_without_git_discovery() { + let directory = tempfile::tempdir().unwrap(); + let resolver = FixedCliConnectionResolver { + backend_url: "http://configured-backend.example", + }; + let args = [ + vec![ + "--workspace-id=repo-free", + "--runtime-id", + "runtime-a", + "--worker-id", + "worker-a", + "--workspace", + ] + .into_iter() + .map(String::from) + .collect::>(), + vec![directory.path().display().to_string()], + ] + .concat(); + let Mode::Tui { + target, + mode: + LaunchMode::OpenWorker { + runtime_id, + worker_id, + }, + .. + } = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap() + else { + panic!("expected Backend Worker attach mode"); + }; + assert_eq!( + target.resolve().unwrap(), + client::ResolvedTarget::Backend { + base_url: "http://configured-backend.example".to_string(), + workspace_id: "repo-free".to_string(), + } + ); + assert_eq!(runtime_id, "runtime-a"); + assert_eq!(worker_id, "worker-a"); + } + + #[test] + fn explicit_workspace_conflicts_with_local_selection() { + let resolver = FixedCliConnectionResolver { + backend_url: "http://unused.example", + }; + let args = ["--local", "--workspace-id", "repo-free"].map(String::from); + let error = parse_args_slice_with_connection_resolver(&args, &resolver).unwrap_err(); + assert_eq!( + error.to_string(), + "--local and --workspace-id are mutually exclusive" + ); + } + + #[test] + fn init_rejects_non_git_repository_instead_of_creating_repo_free_workspace() { + let directory = tempfile::tempdir().unwrap(); + let args = vec![ + "--display-name".to_string(), + "Research".to_string(), + "--repository-key".to_string(), + "main".to_string(), + "--repository".to_string(), + directory.path().display().to_string(), + ]; + let error = parse_init_args(&args, Some("http://backend.example".to_string())).unwrap_err(); + assert!( + error + .to_string() + .contains("not inside a readable Git repository") + ); + } + #[test] fn init_parsing_uses_git_root_without_writing_repository_local_identity() { let repository = tempfile::tempdir().unwrap(); @@ -3398,9 +3708,9 @@ backend = "shared" } #[test] - fn parse_workspace_command_is_removed_from_yoi_surface() { + fn workspace_creation_does_not_restore_removed_workspace_server_command() { let err = parse_args_from(["workspace", "serve", "--listen", "127.0.0.1:0"]).unwrap_err(); - assert_eq!(err.to_string(), "unknown command `workspace`"); + assert_eq!(err.to_string(), "unknown yoi workspace subcommand `serve`"); } #[test] diff --git a/crates/yoi/src/workspace_bootstrap.rs b/crates/yoi/src/workspace_bootstrap.rs index 6daf87c85..f141e8f00 100644 --- a/crates/yoi/src/workspace_bootstrap.rs +++ b/crates/yoi/src/workspace_bootstrap.rs @@ -19,6 +19,13 @@ pub(crate) struct InitOptions { pub(crate) default_ref: Option, } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct WorkspaceCreateOptions { + pub(crate) backend_url: String, + pub(crate) display_name: String, + pub(crate) repository: Option, +} + pub(crate) async fn run_init( options: InitOptions, ) -> Result { @@ -26,27 +33,56 @@ pub(crate) async fn run_init( .repository_root .to_str() .ok_or_else(|| ParseError("the repository root is not valid UTF-8".to_string()))?; + run_workspace_create(WorkspaceCreateOptions { + backend_url: options.backend_url, + display_name: options.display_name, + repository: Some(CreateBackendWorkspaceRepository { + repository_key: options.repository_key, + uri: repository_uri.to_string(), + default_ref: options.default_ref, + }), + }) + .await +} + +pub(crate) async fn run_workspace_create( + options: WorkspaceCreateOptions, +) -> Result { let target = BackendWorkspaceCatalogTarget { base_url: options.backend_url.clone(), }; - let response = create_backend_workspace( - &target, - &CreateBackendWorkspaceRequest { - operation_key: uuid::Uuid::now_v7().to_string(), - display_name: options.display_name, - repository: CreateBackendWorkspaceRepository { - repository_key: options.repository_key, - uri: repository_uri.to_string(), - default_ref: options.default_ref, - }, - }, - ) - .await - .map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?; + let request = workspace_create_request(options.display_name, options.repository); + let response = create_backend_workspace(&target, &request) + .await + .map_err(|error| ParseError(format!("Backend rejected Workspace creation: {error}")))?; record_workspace_backend_routing(&response.workspace.workspace_id, &options.backend_url)?; Ok(response) } +fn workspace_create_request( + display_name: String, + repository: Option, +) -> CreateBackendWorkspaceRequest { + CreateBackendWorkspaceRequest { + operation_key: uuid::Uuid::now_v7().to_string(), + display_name, + repository, + } +} + +pub(crate) fn workspace_creation_message(response: &server_api::WorkspaceCreateResponse) -> String { + match &response.repository { + Some(repository) => format!( + "Initialized Workspace '{}' for repository '{}'", + response.workspace.display_name, repository.repository_key + ), + None => format!( + "Created Workspace '{}' (workspace-id: {}) without an initial repository", + response.workspace.display_name, response.workspace.workspace_id + ), + } +} + pub(crate) fn select_backend_workspace_for_repository( base_url: &str, repository_path: &Path, @@ -333,6 +369,85 @@ mod tests { } } + #[test] + fn workspace_creation_wire_request_omits_repository_for_repo_free_creation() { + let request = workspace_create_request("Research".to_string(), None); + let body = serde_json::to_value(request).unwrap(); + assert_eq!(body["display_name"], "Research"); + assert!( + body.get("repository") + .is_none_or(serde_json::Value::is_null) + ); + } + + #[test] + fn git_workspace_creation_wire_request_preserves_initial_repository() { + let request = workspace_create_request( + "Development".to_string(), + Some(CreateBackendWorkspaceRepository { + repository_key: "platform".to_string(), + uri: "/checkout/platform".to_string(), + default_ref: Some("develop".to_string()), + }), + ); + let body = serde_json::to_value(request).unwrap(); + assert_eq!( + body["repository"], + serde_json::json!({ + "repository_key": "platform", + "uri": "/checkout/platform", + "default_ref": "develop", + }) + ); + } + + #[test] + fn creation_response_with_null_repository_reports_workspace_including_on_replay() { + for replayed in [false, true] { + let body = serde_json::json!({ + "workspace": workspace_summary("workspace-a", "Research"), + "repository": null, + "config_revision": 0, + "request_fingerprint": "fingerprint", + "replayed": replayed, + }); + let response = serde_json::from_value(body).unwrap(); + assert_eq!( + workspace_creation_message(&response), + "Created Workspace 'Research' (workspace-id: workspace-a) without an initial repository" + ); + } + } + + #[test] + fn creation_response_with_repository_preserves_git_init_message() { + let body = serde_json::json!({ + "workspace": workspace_summary("workspace-a", "Development"), + "repository": { + "workspace_id": "workspace-a", + "repository_key": "platform", + "kind": "git", + "provider": "builtin:git", + "source": {"kind": "local_path", "uri": "/checkout/platform"}, + "default_ref": "develop", + "source_revision": 1, + "source_fingerprint": "fingerprint", + "observed_status": "unverified", + "observed_at": null, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + }, + "config_revision": 1, + "request_fingerprint": "fingerprint", + "replayed": false, + }); + let response = serde_json::from_value(body).unwrap(); + assert_eq!( + workspace_creation_message(&response), + "Initialized Workspace 'Development' for repository 'platform'" + ); + } + #[test] fn repository_catalog_selection_handles_single_zero_and_multiple_matches() { let repository = tempfile::tempdir().unwrap(); diff --git a/docs/design/merge-request-wip-projection.md b/docs/design/merge-request-wip-projection.md index 4d314a7a2..cdf991eaf 100644 --- a/docs/design/merge-request-wip-projection.md +++ b/docs/design/merge-request-wip-projection.md @@ -24,7 +24,6 @@ Collection interface `yoi.merge-request/collection/v1`: | Native operation | Existing Tool | Preserved authority inputs | | --- | --- | --- | | `open` | `OpenMergeRequest` | Ticket, repository key, source selector, target selector, summary | -| `complete_ticket` | `CompleteTicket` | Ticket, operation ID, current item revision, exact linked-MR result set, requirement approval event | Item interface `yoi.merge-request/item/v1`: @@ -39,9 +38,9 @@ Typical role surfaces are therefore: - Coder: collection `open`; item `read`. - Reviewer: item `read` and `review`. -- Orchestrator: collection `complete_ticket`; item `read`, `check_readiness`, and `complete`. +- Orchestrator: item `read`, `check_readiness`, and `complete`. -`complete_ticket` is deliberately collection-scoped because its subject is a Ticket and an exact set of linked Merge Request results rather than one route-bound MR. It keeps those identities explicit. 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. +`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. ## Preserved review and integration gates @@ -55,8 +54,8 @@ The native projection delegates to the same ordinary tools and REST endpoints. I - source movement invalidating source approval while target-only movement preserves unchanged-source review; - unresolved request-change and revoked/stale approval rejection; - current target observation, target-before/after proof, strategy, and conflict-resolution checks; -- Orchestrator-only Merge Request and Ticket completion; -- immutable integration/result evidence and exact linked-result-set Ticket completion. +- Orchestrator-only Merge Request integration; +- immutable integration/result evidence and exact linked-result-set requirement attestation. The Worldspace adapter performs no Git or provider integration. A visible `complete` operation remains unusable without the ordinary Backend's authenticated online Orchestrator source and integration evidence. @@ -64,7 +63,7 @@ The Worldspace adapter performs no Git or provider integration. A visible `compl `read` exposes the existing bounded thread page with optional `after` and a `1..=200` limit. Responses keep linked Tickets as references/data, so Ticket or Objective native projections are not prerequisites. Descriptors never expand repository diffs, unbounded thread history, credentials, or capability material. -A successful item read records a response fingerprint. A successful item mutation advances a local mutation generation. Opening records the returned MR and adds its native path to the result. Ticket completion invalidates every MR route named in its exact result set because releasing the assignment can change later ref observations. The common WIP Host rejects calls made with stale object/interface validators; callers must rediscover before deciding whether to call again. +A successful item read records a response fingerprint. A successful item mutation advances a local mutation generation. Opening records the returned MR and adds its native path to the result. Ticket decisions advance the Ticket provider's object generation, not the MR integration state or proofs. Ending Ticket work can affect later live source observation, so clients reread MR authority before MR actions; Backend checks remain authoritative. The common WIP Host rejects calls made with stale object/interface validators; callers must rediscover before deciding whether to call again. Optional fields preserve ordinary JSON-input semantics: omission remains omission and explicit WIP unit/null remains JSON `null` for schema and permission matching. The reconstructed object is validated against the original Tool JSON Schema before the Tool executes. diff --git a/docs/design/ticket-evidence.md b/docs/design/ticket-evidence.md new file mode 100644 index 000000000..9b9497e5b --- /dev/null +++ b/docs/design/ticket-evidence.md @@ -0,0 +1,25 @@ +# 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. + +## Evidence subjects + +- An open MR uses the Repository/Runtime authority's provider-resolved current source. No Server-local checkout is assumed. Failure is a tagged `source_ref_observation: { status: "unavailable", code }`, not proof that review is stale. Ticket review status is `unknown`; `missing` includes `source_ref_unavailable`, not `review_after_rescope` merely because observation failed. +- A merged MR uses its stored MergeResult's `approved_source_ref` and `approval_event_id`. Branch deletion/movement, checkout absence, and provider outage do not trigger source observation. The summary's `current_subject_ref` is the immutable merged source and observation is `not_required`. +- Integration approval must exist, approve that exact source, predate the merge, have matching ReviewRequested evidence, and not have been revoked. `integration_evidence_error` is a bounded machine code for invalid stored evidence. `merged` alone never creates approval. + +A merged MR's review status/timestamps describe **integration approval**, not a later requirement approval. Paged MR detail reads evaluate the full stored result before paging; UI must not infer approval from a merge event appearing in one page. + +## Current requirements + +`merge_request::requirement_approval` evaluates Ticket read-side MR evidence; it is not a Ticket completion gate. It validates an effective Reviewer approval of the current item revision and the **exact complete linked MR/source snapshot**, including the hosting MR's own source and corresponding ReviewRequested event. There is no primary-MR fallback, comment-derived approval, or timestamp-only freshness rule. A post-integration review may attest a new revision/snapshot without replacing MergeResult or its integration approval. + +`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. + +CompleteTicket belongs to the Ticket domain. It records the authenticated actor's reason/result and optional references using current item revision/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. + +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. + +Query candidate SQL narrows Ticket metadata, not live evidence. Evidence predicates are evaluated from the same full MR projection as ShowTicket, after source observation. Bounded scans can return an empty page with `has_more` and a cursor after the last scanned candidate; callers must follow that cursor rather than treat an empty page as exhaustion. This prevents stale SQL/report heuristics or early nonmatching candidates from hiding later evidence matches. diff --git a/docs/design/workspace-optional-repository.md b/docs/design/workspace-optional-repository.md new file mode 100644 index 000000000..9d585bfa7 --- /dev/null +++ b/docs/design/workspace-optional-repository.md @@ -0,0 +1,56 @@ +# Workspace creation with optional Repository intent + +A Workspace is usable without a Repository. Its initial Git Repository is an +optional asset, not an identity or a prerequisite for connection. Creation does +not initialize a Git repository or infer a replacement asset. + +## Creation and replay contract + +`POST /api/workspaces` accepts `operation_key`, `display_name`, and an optional +`repository` intent. Omitted and explicit `null` both mean no initial Repository. +An object is validated normally: a partial object, invalid URI, or invalid key is +not treated as omission. Ownership comes from the authenticated request actor, +not a caller-supplied owner field. + +The bootstrap transaction persists the Workspace, optional Repository, initial +config, memory settings, Ticket/Objective/Worker resource-key counters, active +signing identity, and create receipt together. Signing-material provisioning +retains its existing durable reservation/recovery protocol; a failed bootstrap +leaves no partially created Workspace or Repository. + +The fingerprint includes normalized creation intent and owner identity. No +Repository differs from any Repository intent; repository-present fingerprints +keep the existing encoding so saved create receipts remain replayable. The same +operation key and input converge, including concurrent calls; changed input is a +conflict. No schema migration is needed for the existing fingerprint/Workspace +receipts. + +A replay is not a historical snapshot. It returns the current Workspace and +config revision. If creation supplied a Repository, it returns the current +record **at the originally requested key**. Removal or renaming away from that +key produces `repository: null`; registration at that key later returns that +current record. Replay never re-registers or resets an asset. If creation omitted +a Repository, replay always returns `null`, even after other assets are added. + +## Entry points and zero-Repository use + +- Web creation requires only a display name. Optional Repository fields are + disclosed explicitly; Settings retains the later-registration entry point. +- `yoi [--backend URL] workspace create --display-name NAME` creates without Git + discovery. Select the returned identity with `yoi --workspace-id ID`. +- `yoi init` remains Git-oriented and still requires an explicit Repository key. +- The TUI creation picker allows Enter at the optional URI prompt to skip Git + registration. Workspace selection and ordinary embedded Worker startup do not + require a Repository or Workdir. Operations that use Git/Workdirs retain their + own prerequisites. +- Empty Repository lists are valid, without a missing-configuration warning. + Reopening and deletion use the usual Workspace authority and lifecycle guards; + in particular, the last-accessible-Workspace deletion guard is unchanged. + +Optional creation preserves the existing owner/member responsibility split: +settings are owner-controlled, while ordinary viewing and use are member-facing. +Non-ownership alone is not a reason to deny ordinary viewing. Existing +member-facing non-owner reads, owner-gated settings mutations, owner-scoped +catalog discovery, authentication and operation-permission checks are unchanged. +This creation change does not introduce a membership authority or an owner-only +viewing filter. diff --git a/docs/report/subworker-cwd-tool-paths.md b/docs/report/subworker-cwd-tool-paths.md new file mode 100644 index 000000000..e721a630d --- /dev/null +++ b/docs/report/subworker-cwd-tool-paths.md @@ -0,0 +1,7 @@ +# SubWorker cwd changes logical tool path interpretation + +During T-716 delegation, a child spawned with `cwd: crates/merge-request` and explicit read grants for `docs/development/rust-testing-strategy.md` could not read that path. `Read` was presented as Workdir-root-relative, but the observed error resolved it as `crates/merge-request/docs/development/rust-testing-strategy.md`. The same prefixing occurred for `crates/merge-request/src/lib.rs`; parent traversal was rejected. + +The child made no edits. Rebinding a child at the checkout root with read-only `.` plus writable `crates/merge-request` let it read the required strategy and edit only the delegated crate. No denied write was bypassed. + +Suggested improvement: make logical path resolution and child cwd semantics consistent in the tool description, capability checks, and execution. If cwd affects filesystem tools, expose the effective logical root explicitly and retain an unambiguous way to address other granted read paths. Scope validation should reject unusable grants at spawn rather than leave the child discovering path-prefix behavior. diff --git a/docs/report/t718-subworker-path-context.md b/docs/report/t718-subworker-path-context.md new file mode 100644 index 000000000..fb70a6329 --- /dev/null +++ b/docs/report/t718-subworker-path-context.md @@ -0,0 +1,15 @@ +# T-718: SubWorker の cwd・参照コンテキストに関する障壁 + +## 観測 + +T-718 の実装を `crates/ticket` / `crates/worker` / `crates/merge-request` に分担した。SubWorkerSpawn の cwd を各 crate に設定すると、子のファイル tool は `src/lib.rs` 等で成功する一方、親で使える repository-root-relative な `crates//src/...` は crate prefix が二重になって失敗した。cwd 未指定の Glob は一部の子で transient bash-output 側のパスを logical path として扱い失敗した。 + +また builtin default の子には ShowTicket が公開されず、別途 read scope に含めたテスト規則の親 root-relative path も子の cwd から参照できなかった。親が authoritative Ticket とテスト規則を先に読み、要件・規則・インターフェースを本文で転送して作業を継続した。 + +## 改善案 + +- 子の startup context に実際の Workdir attachment alias・logical root・tool cwd を明示する。 +- cwd と path root の関係を tool schema / Spawn 説明と一致させ、cwd 外に委譲した read-only scope の参照方法も示す。 +- Ticket implementation の子へ typed Ticket read を委譲できない場合は、親に authoritative item revision と本文を渡す責任があることを明示する。 + +本件では委譲 scope を広げたり Backend storage を直接操作する回避はしていない。製品要件の追加ではなく、分担時の tooling/context で発生した調整コストの記録である。 diff --git a/openapi/server-api.json b/openapi/server-api.json index 9eb8e17d3..7c6774cd1 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -301,11 +301,23 @@ "BrowserCloseTicketRequest": { "additionalProperties": false, "properties": { + "expected_item_revision": { + "type": "string" + }, + "expected_state": { + "$ref": "#/components/schemas/BrowserTicketWorkflowState" + }, + "operation_key": { + "type": "string" + }, "resolution": { "type": "string" } }, "required": [ + "operation_key", + "expected_item_revision", + "expected_state", "resolution" ], "type": "object" @@ -459,30 +471,34 @@ "BrowserTransitionTicketStateRequest": { "additionalProperties": false, "properties": { - "author": { - "type": [ - "string", - "null" - ] - }, "body": { "type": [ "string", "null" ] }, + "expected_item_revision": { + "type": "string" + }, + "expected_state": { + "$ref": "#/components/schemas/BrowserTicketWorkflowState" + }, + "operation_key": { + "type": "string" + }, "reason": { - "type": [ - "string", - "null" - ] + "type": "string" }, "state": { "$ref": "#/components/schemas/BrowserTicketWorkflowState" } }, "required": [ - "state" + "state", + "operation_key", + "expected_item_revision", + "expected_state", + "reason" ], "type": "object" }, @@ -1075,6 +1091,37 @@ ], "type": "object" }, + "CompleteTicketRequest": { + "additionalProperties": false, + "properties": { + "expected_item_revision": { + "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_item_revision", + "expected_state", + "reason" + ], + "type": "object" + }, "CompletionContext": { "properties": { "argument": { @@ -10199,6 +10246,45 @@ ], "type": "object" }, + "TicketCompletion": { + "additionalProperties": false, + "description": "Audited completion independent of repositories, Git, or Merge Requests.", + "properties": { + "author": { + "default": null, + "type": [ + "string", + "null" + ] + }, + "expected_item_revision": { + "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": [ + "expected_item_revision", + "expected_state", + "operation_key", + "reason" + ], + "type": "object" + }, "TicketDependencyCheck": { "properties": { "blockers": { @@ -11221,6 +11307,12 @@ "null" ] }, + "integration_evidence_error": { + "type": [ + "string", + "null" + ] + }, "merge_request_id": { "type": "string" }, @@ -11263,6 +11355,9 @@ "selector_to": { "type": "string" }, + "source_ref_observation": { + "$ref": "#/components/schemas/TicketSourceRefObservation" + }, "state": { "type": "string" }, @@ -11276,7 +11371,8 @@ "state", "review_status", "selector_to", - "updated_at" + "updated_at", + "source_ref_observation" ], "type": "object" }, @@ -12069,39 +12165,93 @@ }, "type": "object" }, - "TicketStateChange": { + "TicketSourceRefObservation": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "status": { + "const": "observed", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "code": { + "type": "string" + }, + "status": { + "const": "unavailable", + "type": "string" + } + }, + "required": [ + "status", + "code" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "status": { + "const": "not_required", + "type": "string" + } + }, + "required": [ + "status" + ], + "type": "object" + } + ] + }, + "TicketStateUpdate": { + "additionalProperties": false, + "description": "External state mutation with item/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" ] }, - "body": { - "$ref": "#/components/schemas/MarkdownText" + "expected_item_revision": { + "type": "string" }, - "from": { + "expected_state": { + "$ref": "#/components/schemas/TicketWorkflowState" + }, + "operation_key": { "type": "string" }, "reason": { "type": "string" }, "references": { + "default": [], "items": { "$ref": "#/components/schemas/TicketReference" }, "type": "array" }, - "to": { - "type": "string" + "state": { + "$ref": "#/components/schemas/TicketWorkflowState" } }, "required": [ - "from", - "to", - "reason", - "body", - "references" + "expected_item_revision", + "expected_state", + "state", + "operation_key", + "reason" ], "type": "object" }, @@ -13906,7 +14056,7 @@ }, "WorkspaceCreateRequest": { "additionalProperties": false, - "description": "Request for atomically creating a Workspace and its initial Repository.", + "description": "Request for atomically creating a Workspace with an optional initial Repository.", "properties": { "display_name": { "type": "string" @@ -13915,19 +14065,25 @@ "type": "string" }, "repository": { - "$ref": "#/components/schemas/InitialRepositoryIntent" + "anyOf": [ + { + "$ref": "#/components/schemas/InitialRepositoryIntent" + }, + { + "type": "null" + } + ] } }, "required": [ "operation_key", - "display_name", - "repository" + "display_name" ], "type": "object" }, "WorkspaceCreateResponse": { "additionalProperties": false, - "description": "Response returned after atomically creating a Workspace and its first Repository.", + "description": "Response returned after atomically creating a Workspace.\n\nOn replay, Workspace/config and the Repository at the originally requested key are\ncurrent authority, not historical snapshots. Repository is null when creation omitted\nit or that key has since been removed/renamed; replay never recreates an asset.", "properties": { "config_revision": { "format": "uint64", @@ -13939,7 +14095,14 @@ "type": "boolean" }, "repository": { - "$ref": "#/components/schemas/WorkspaceRepositoryRecord" + "anyOf": [ + { + "$ref": "#/components/schemas/WorkspaceRepositoryRecord" + }, + { + "type": "null" + } + ] }, "request_fingerprint": { "type": "string" @@ -13950,7 +14113,6 @@ }, "required": [ "workspace", - "repository", "config_revision", "request_fingerprint", "replayed" @@ -15481,7 +15643,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf" + "x-yoi-source-digest": "sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0" }, "openapi": "3.1.0", "paths": { @@ -31941,6 +32103,143 @@ }, "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": { @@ -33500,6 +33799,16 @@ }, "description": "Error response" }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, "500": { "content": { "application/json": { @@ -33550,7 +33859,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TicketStateChange" + "$ref": "#/components/schemas/TicketStateUpdate" } } }, @@ -33610,6 +33919,16 @@ }, "description": "Error response" }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, "500": { "content": { "application/json": { @@ -33670,7 +33989,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TicketStateChange" + "$ref": "#/components/schemas/TicketStateUpdate" } } }, @@ -33730,6 +34049,143 @@ }, "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": { @@ -33890,7 +34346,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/TicketStateChange" + "$ref": "#/components/schemas/TicketStateUpdate" } } }, @@ -33950,6 +34406,16 @@ }, "description": "Error response" }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, "500": { "content": { "application/json": { @@ -34000,7 +34466,7 @@ "content": { "application/json": { "schema": { - "$ref": "#/components/schemas/MarkdownText" + "$ref": "#/components/schemas/TicketCompletion" } } }, @@ -34060,6 +34526,16 @@ }, "description": "Error response" }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, "500": { "content": { "application/json": { diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index bbee8320b..34db081a0 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -16,14 +16,14 @@ 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: keep the Ticket and assignment in progress while remaining linked MRs are implemented, reviewed, or 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. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR/source-result set, identify the effective Reviewer approval that attests that exact snapshot and judges the requirements satisfied, then call `CompleteTicket` with the item revision, MR IDs, and that requirement approval event ID. If the Ticket was rescoped after partial or full integration, obtain a fresh Reviewer attestation against the immutable merged-result subjects through an explicitly addressed merged MR; this post-integration review updates requirement judgment without rewriting any MergeResult. `CompleteTicket` alone moves the Ticket to `done` and releases the assignment atomically; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint. +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. Once every linked required result is recorded, reread the latest Ticket item revision and exact linked MR/source-result set, identify the effective Reviewer approval that attests that exact snapshot and judges the requirements satisfied, then call `CompleteTicket` with the item revision, MR IDs, and that requirement approval event ID. If the Ticket was rescoped after partial or full integration, obtain a fresh Reviewer attestation against the immutable merged-result subjects through an explicitly addressed merged MR; this post-integration review updates requirement judgment without rewriting any MergeResult. `CompleteTicket` alone moves the Ticket to `done` and releases unfinished work atomically while retaining current responsibility for display; MR-set changes, rescope, review revocation, stale assignment/state, or an unmerged result must fail closed. Retry an already-recorded operation only with the same fingerprint. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. Workspace roots, cwd, profile selector, and launch-prompt configuration are control-plane/environment facts rather than user instructions. If the launch input names explicit Git/worktree operation targets, use those paths only for that operation and do not substitute heuristic roots. -Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no current Ticket assignment, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat assignment, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization. +Use `WorkerRemove` only for a terminal or authoritatively reassigned non-internal Coder after implementation, review, fix, merge/commit, and report handoffs are complete. Do not remove a Coder merely because one turn completed or it is temporarily idle; retain it while review or request-changes work can still return. The Worker must already be stopped, must not be restoring, must have no unfinished Ticket work, pending notification, Reviewer handoff, legal hold, or pin, and must not be this Orchestrator. Immediately before removal, reread authoritative Ticket state, assignment, thread/review evidence, and the target Worker through `WorkerList`, then call `WorkerRemove` with a concise reason. Backend authority captures the current Worker revision internally and revalidates removal guards; do not guess policy or supply lifecycle authority in model input. After removal, reread the Worker catalog and attachment state. Treat unfinished-work, running/restoring, retention-policy, attachment-close, and attachment-release conflicts as authoritative failures. `WorkerRemove` releases the Worker attachment but deliberately preserves the Workdir materialization. -Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After all `CompleteMergeRequest` operations and guarded `CompleteTicket`, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no current Ticket assignment, then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied. +Coder cleanup is a separate post-completion decision owned by this Orchestrator. Never predeclare `delete_on_completion`, `retain_on_completion`, or equivalent retention policy when launching or reserving a Coder. After all `CompleteMergeRequest` operations and guarded `CompleteTicket`, perform one cleanup pass before ending the orchestration turn: reread the current Ticket and `WorkerList`, verify completion is authoritative and the Coder has no unfinished Ticket work (terminal current responsibility may remain), then inspect the Coder Worker, Workdir attachment and occupancy, repository cleanliness, ownership, provider availability, and any other current use of that Workdir. For Worker control, use the exact subject returned by `WorkerList`. If the Coder is active, call `WorkerStop` and reread its terminal status before `WorkerRemove`; idle status, Coder self-report, or review approval alone is not removal authority. Retain an existing or still-needed Workdir. Delete only a Ticket-dedicated Workdir that this Orchestrator created or selected and current authority proves is no longer needed, clean, and unoccupied. -For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; unassign it through the available orchestration authority; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `CurrentAssignment` means unassign and reread before retrying `WorkerRemove`. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, unassign, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, assignment, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions. +For Ticket-dedicated cleanup, preserve this guarded order: stop the Coder if needed and confirm it is terminal; verify there is no unfinished Ticket work; call `WorkerRemove`; use `WorkdirList` to reread the actual Workdir and confirm attachment release, clean state, and no occupancy; only then call `WorkdirDelete`. Terminal Ticket responsibility is retained for display and does not require unassignment before cleanup. Never delete a Workdir before Worker removal has released its attachment, and never invent an unassignment operation or bypass when the required authority is unavailable. `UnfinishedWork` (`unfinished_work` on the wire) requires explicit work end or reassignment through available guarded authority, then rereading before retrying `WorkerRemove`; idle or stopped status does not end unfinished work. Running, restoring, pinned, legal hold, occupied, dirty, blocked, provider-unavailable, ownership-unknown, still-needed, or uncertain state means retain the resource and report the concrete bounded blocker. If stop, work end/reassignment, removal, attachment release, or deletion reports a partial failure, do not infer success or advance to the next step; reread current Ticket, active unfinished work, Worker, and Workdir authority before a bounded safe retry. Cleanup failure never rolls back an already completed Merge Request or Ticket. Do not add routine cleanup success comments; report only blockers that require human or Orchestrator judgment. Never force removal, discard changes, or retry from stale assumptions. diff --git a/web/workspace/deno.json b/web/workspace/deno.json index e17a7fb8b..733276e24 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -8,6 +8,7 @@ "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 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 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", "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", "build": "deno run -A npm:vite@8.3.2 build", "preview": "deno run -A npm:vite@8.3.2 preview", "generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index f85678a77..6b0103cdc 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:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) +// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) // 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/legacy-server-api.ts b/web/workspace/src/lib/generated/legacy-server-api.ts index aeb99cdaf..955ae4b2d 100644 --- a/web/workspace/src/lib/generated/legacy-server-api.ts +++ b/web/workspace/src/lib/generated/legacy-server-api.ts @@ -34,7 +34,7 @@ export type WorkspaceRepositoryRecord = { export type WorkspaceCreateResponse = { workspace: WorkspaceSummary; - repository: WorkspaceRepositoryRecord; + repository: WorkspaceRepositoryRecord | null; config_revision: number; request_fingerprint: string; replayed: boolean; diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 2ac059cfb..2f7807e1b 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:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) +// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) // 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 9197abbc9..f49480ac0 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:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) +// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) // 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 530b8fbe0..09444105b 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,13 +1,13 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) +// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) // 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 = { resolution: string; }; +export type BrowserCloseTicketRequest = { expected_item_revision: 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; }; @@ -19,7 +19,7 @@ export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "impleme export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed"; -export type BrowserTransitionTicketStateRequest = { author?: string | null; body?: string | null; reason?: string | null; state: BrowserTicketWorkflowState; }; +export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_item_revision: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; }; export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; }; @@ -117,7 +117,7 @@ export type TicketListResponse = { invalid_records: Array; export type TicketMarkReadyRequest = { intake_summary?: TicketIntakeSummary | null; operation_key: string; reason?: string | null; }; -export type TicketMergeRequestSummary = { current_subject_ref?: 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; state: string; updated_at: string; }; +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 TicketQueueOutcome = { queued_tickets: Array; requested_ticket: string; }; @@ -131,6 +131,8 @@ export type TicketRoleAssignmentRecord = { assigned_at: string; assigned_by: str export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: string; assignment_id: string; principal: TicketAssignmentPrincipal; role: string; }; +export type TicketSourceRefObservation = { status: "observed"; } | { code: string; status: "unavailable"; } | { status: "not_required"; }; + export type TicketStatus = "Open" | "Closed"; export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; }; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index cf273be40..fcceed331 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:fd06f382c6d33a5c3cc98ef9e264545923c53fa95a9be555a85680f98c6065da) +// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) // 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 2ec93ec9e..92c3b1338 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:a6a7147c9a2f465a5da044e9ba21947cb3d86eacbfe3905696483917e794d5cf) +// Input contract: 0.1.0 (sha256:3c3d4e9f55fca26e5833a07da29a6030e4beeedca645ff1c4ef1583c6c29abe0) // 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 7b6c52d73..11693f801 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -4,6 +4,7 @@ declare const Deno: { import { parseMergeRequestDetailResponse, + parseMergeRequestListResponse, parseObjectiveListResponse, parseTicketDetail, parseTicketListResponse, @@ -80,8 +81,8 @@ Deno.test("Ticket Browser parser rejects unknown fields and out-of-range limits" ); }); -Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => { - const fixture = { +function ticketDetailFixture() { + return { action_eligibility: { blockers: [], can_assign_orchestrator: false, @@ -142,7 +143,10 @@ Deno.test("Ticket Browser parser accepts target collections and rejects singular ], title: "Ticket", }; +} +Deno.test("Ticket Browser parser accepts target collections and rejects singular target authority", () => { + const fixture = ticketDetailFixture(); const parsed = parseTicketDetail(fixture); assertEquals(parsed.targets.length, 2); assertEquals(parsed.targets[0]?.repository_key, "docs"); @@ -168,6 +172,157 @@ Deno.test("Ticket Browser parser accepts target collections and rejects singular ); }); +const mergeRequestSummary = { + merge_request_id: "mr-1", + repository_key: "main", + state: "open", + selector_from: "work/T-1", + selector_to: "develop", + updated_at: "2026-10-08T00:00:00Z", + current_subject_ref: "source-1", + source_ref_observation: { status: "observed" }, + integration_evidence_error: null, + review_status: "approved", + review_subject_ref: "source-1", + review_requested_at: null, + review_submitted_at: null, + review_excerpt: null, +}; + +function mergeRequestListFixture(summary: unknown) { + return { + items: [{ summary, ticket_ids: ["ticket-id"], thread_event_count: 2 }], + next_cursor: null, + }; +} + +Deno.test("Merge Request summaries preserve live observation and immutable merged evidence separately", () => { + for ( + const summary of [ + mergeRequestSummary, + { + ...mergeRequestSummary, + current_subject_ref: null, + source_ref_observation: { + status: "unavailable", + code: "repository_unavailable", + }, + review_status: "pending", + }, + { + ...mergeRequestSummary, + state: "merged", + source_ref_observation: { status: "not_required" }, + }, + { + ...mergeRequestSummary, + state: "merged", + source_ref_observation: { status: "not_required" }, + integration_evidence_error: "approval_event_missing", + review_status: "none", + }, + ] + ) { + const listed = parseMergeRequestListResponse( + mergeRequestListFixture(summary), + ); + const detailed = parseTicketDetail({ + ...ticketDetailFixture(), + merge_requests: [summary], + merge_request: summary, + }); + for ( + const parsed of [ + listed.items[0]?.summary, + detailed.merge_requests[0], + detailed.merge_request, + ] + ) { + assertEquals( + parsed?.source_ref_observation, + summary.source_ref_observation, + ); + assertEquals(parsed?.current_subject_ref, summary.current_subject_ref); + assertEquals( + parsed?.integration_evidence_error, + summary.integration_evidence_error, + ); + assertEquals(parsed?.review_subject_ref, summary.review_subject_ref); + } + } +}); + +Deno.test("Merge Request summaries reject missing and malformed tagged source observations", () => { + for ( + const [observation, message] of [ + [undefined, "source_ref_observation must be an object"], + [null, "source_ref_observation must be an object"], + ["observed", "source_ref_observation must be an object"], + [{ status: "unknown" }, "status is invalid"], + [{ status: "unavailable" }, "code must be a string"], + [{ status: "unavailable", code: 42 }, "code must be a string"], + [ + { status: "unavailable", code: "x".repeat(262_145) }, + "code exceeds its length limit", + ], + [{ status: "observed", code: "stale" }, "unknown field code"], + [{ status: "not_required", code: "stale" }, "unknown field code"], + [ + { status: "unavailable", code: "missing", extra: true }, + "unknown field extra", + ], + ] as const + ) { + assertThrows( + () => + parseMergeRequestListResponse(mergeRequestListFixture({ + ...mergeRequestSummary, + source_ref_observation: observation, + })), + message, + ); + } +}); + +Deno.test("Merge Request summaries require a nullable bounded integration evidence error", () => { + for ( + const [error, message] of [ + [undefined, "integration_evidence_error must be a string"], + [false, "integration_evidence_error must be a string"], + [ + "x".repeat(262_145), + "integration_evidence_error exceeds its length limit", + ], + ] as const + ) { + assertThrows( + () => + parseMergeRequestListResponse(mergeRequestListFixture({ + ...mergeRequestSummary, + integration_evidence_error: error, + })), + message, + ); + } +}); + +Deno.test("done Ticket state is preserved when current requirement evidence is incomplete", () => { + const fixture = ticketDetailFixture(); + const parsed = parseTicketDetail({ + ...fixture, + state: "done", + evidence: { ...fixture.evidence, missing: ["review_after_rescope"] }, + merge_requests: [{ + ...mergeRequestSummary, + state: "merged", + source_ref_observation: { status: "not_required" }, + }], + }); + assertEquals(parsed.state, "done"); + assertEquals(parsed.evidence.complete_for_integration, false); + assertEquals(parsed.merge_requests[0]?.review_status, "approved"); +}); + Deno.test("Ticket Browser parser validates Ticket create responses", () => { const parsed = parseTicketRecordRef({ id: "ticket-id", diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index 8f0fde5cb..0eb784931 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -446,6 +446,23 @@ function parseActionEligibility( }; } +function parseSourceRefObservation( + value: unknown, + label: string, +): TicketMergeRequestSummary["source_ref_observation"] { + const item = object(value, label); + const status = string(item.status, `${label}.status`); + if (status === "observed" || status === "not_required") { + exact(item, ["status"], label); + return { status }; + } + if (status === "unavailable") { + exact(item, ["status", "code"], label); + return { status, code: string(item.code, `${label}.code`) }; + } + throw new Error(`${label}.status is invalid`); +} + function parseTicketMergeRequestSummary( value: unknown, label: string, @@ -453,6 +470,8 @@ function parseTicketMergeRequestSummary( const item = object(value, label); exact(item, [ "current_subject_ref", + "integration_evidence_error", + "source_ref_observation", "merge_request_id", "repository_key", "review_excerpt", @@ -470,6 +489,14 @@ function parseTicketMergeRequestSummary( item.current_subject_ref, `${label}.current_subject_ref`, ), + integration_evidence_error: nullableString( + item.integration_evidence_error, + `${label}.integration_evidence_error`, + ), + source_ref_observation: parseSourceRefObservation( + item.source_ref_observation, + `${label}.source_ref_observation`, + ), merge_request_id: string( item.merge_request_id, `${label}.merge_request_id`, diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts new file mode 100644 index 000000000..d2707c2d4 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.test-fixtures.ts @@ -0,0 +1,33 @@ +export const createdWorkspace = { + workspace_id: "home-empty", + owner_account_id: "fixture-owner", + display_name: "New Workspace", + state: "active", + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", +}; + +export const currentInitialRepository = { + workspace_id: createdWorkspace.workspace_id, + repository_key: "platform", + kind: "git", + provider: "git", + source: { kind: "https", uri: "https://example.test/current.git" }, + default_ref: "main", + source_revision: 2, + source_fingerprint: "sha256:current", + observed_status: "unverified", + observed_at: null, + created_at: "2026-01-01T00:00:00Z", + updated_at: "2026-01-02T00:00:00Z", +}; + +export function creationResponse(repository: unknown = null, replayed = false) { + return { + workspace: createdWorkspace, + repository, + config_revision: 1, + request_fingerprint: "sha256:creation", + replayed, + }; +} diff --git a/web/workspace/src/lib/workspace/api/workspace-catalog.ts b/web/workspace/src/lib/workspace/api/workspace-catalog.ts index 587b54839..56afce918 100644 --- a/web/workspace/src/lib/workspace/api/workspace-catalog.ts +++ b/web/workspace/src/lib/workspace/api/workspace-catalog.ts @@ -17,11 +17,11 @@ export type CreateWorkspaceResponse = WorkspaceCreateResponse; export type CreateWorkspaceRequest = { operation_key: string; display_name: string; - repository: { + repository?: { repository_key: string; uri: string; default_ref: string | null; - }; + } | null; }; export class WorkspaceCatalogError extends Error { diff --git a/web/workspace/src/lib/workspace/api/workspace-model.ts b/web/workspace/src/lib/workspace/api/workspace-model.ts index d16e9bacc..566a7b41d 100644 --- a/web/workspace/src/lib/workspace/api/workspace-model.ts +++ b/web/workspace/src/lib/workspace/api/workspace-model.ts @@ -569,7 +569,7 @@ export function parseWorkspaceCreateResponse( response.workspace, "workspace create response.workspace", ), - repository: workspaceRepositoryRecord( + repository: response.repository === null ? null : workspaceRepositoryRecord( response.repository, "workspace create response.repository", ), 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 bcb2cac38..958cf04d8 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -181,6 +181,8 @@ export function dashboardFixture( review_requested_at: updated(10), review_submitted_at: null, current_subject_ref: "abc123", + source_ref_observation: { status: "observed" }, + integration_evidence_error: null, review_subject_ref: "abc123", }, thread_event_count: 1, diff --git a/web/workspace/src/lib/workspace/merge-request-status.test.ts b/web/workspace/src/lib/workspace/merge-request-status.test.ts new file mode 100644 index 000000000..5e3ba1d44 --- /dev/null +++ b/web/workspace/src/lib/workspace/merge-request-status.test.ts @@ -0,0 +1,247 @@ +declare const Deno: { + test(name: string, fn: () => Promise | void): void; + readTextFile(path: URL): Promise; +}; + +import type { TicketMergeRequestSummary } from "#lib/generated/ticket-api.ts"; +import { fixtureDetail } from "./home/dashboard.test-fixtures.ts"; +import { parseTicketDetail } from "./api/ticket-browser.ts"; +import { + currentRequirementApprovalStatus, + sourceReviewFreshness, + summarySourceReviewStatus, +} from "./merge-request-status.ts"; +import type { MergeRequestDetail } from "./api/merge-requests.ts"; + +function assertEquals(actual: unknown, expected: unknown): void { + if (actual !== expected) { + throw new Error( + `Expected ${JSON.stringify(expected)}, received ${ + JSON.stringify(actual) + }`, + ); + } +} + +const summary: TicketMergeRequestSummary = { + merge_request_id: "mr-1", + repository_key: "main", + state: "open", + selector_from: "work/T-1", + selector_to: "develop", + updated_at: "2026-10-08T00:00:00Z", + current_subject_ref: "source-1", + source_ref_observation: { status: "observed" }, + integration_evidence_error: null, + review_status: "approved", + review_subject_ref: "source-1", +}; + +Deno.test("unavailable live observation shows its error rather than claiming review is stale or pending", () => { + assertEquals( + summarySourceReviewStatus({ + ...summary, + current_subject_ref: null, + source_ref_observation: { + status: "unavailable", + code: "repository_unavailable", + }, + review_status: "pending", + }), + "Live source observation unavailable: repository_unavailable.", + ); +}); + +Deno.test("merged summary labels approval as immutable persisted evidence without live observation", () => { + assertEquals( + summarySourceReviewStatus({ + ...summary, + state: "merged", + source_ref_observation: { status: "not_required" }, + }), + "Integration approval recorded for immutable merged source source-1.", + ); +}); + +Deno.test("merged evidence error does not claim a valid integration approval", () => { + assertEquals( + summarySourceReviewStatus({ + ...summary, + state: "merged", + source_ref_observation: { status: "not_required" }, + integration_evidence_error: "approval_event_missing", + }), + "Integration approval unavailable for immutable merged source source-1.", + ); +}); + +Deno.test("observed live source movement requires fresh review but an unchanged ref retains approval", () => { + assertEquals( + summarySourceReviewStatus(summary), + "Live source review: approved for exact ref source-1.", + ); + assertEquals( + summarySourceReviewStatus({ ...summary, current_subject_ref: "source-2" }), + "Fresh live source review required: selector_from moved from source-1 to source-2.", + ); +}); + +Deno.test("done Ticket can retain immutable approval while current requirement evidence is incomplete", () => { + const ticket = parseTicketDetail({ + ...fixtureDetail(), + state: "done", + evidence: { + ...fixtureDetail().evidence, + has_merge_request: true, + has_current_subject_ref: true, + has_commit: true, + has_review_request: true, + review_status: "pending", + approved_current_subject: true, + review_after_rescope: false, + missing: ["review_after_rescope"], + }, + merge_requests: [{ + ...summary, + state: "merged", + source_ref_observation: { status: "not_required" }, + }], + }); + assertEquals(ticket.state, "done"); + assertEquals(ticket.evidence.complete_for_integration, false); + assertEquals( + currentRequirementApprovalStatus(ticket.evidence), + "not established", + ); + const recovered = parseTicketDetail({ + ...ticket, + evidence: { + ...ticket.evidence, + review_after_rescope: true, + review_status: "approved", + complete_for_integration: true, + missing: [], + }, + }); + assertEquals(recovered.evidence.approved_current_subject, true); + assertEquals( + currentRequirementApprovalStatus(recovered.evidence), + "approved", + ); + assertEquals(recovered.state, "done"); + 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 () => { + const source = await Deno.readTextFile( + new URL( + "../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte", + import.meta.url, + ), + ); + for ( + const required of [ + "Current requirement evidence", + "ticket.evidence.complete_for_integration", + "Current requirement approval: {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 (!source.includes(required)) { + throw new Error(`Ticket route must include ${required}`); + } + } +}); + +Deno.test("Merge Request routes distinguish recorded approval from live source observation and expose errors", async () => { + const list = await Deno.readTextFile( + new URL( + "../../routes/w/[workspaceId]/merge-requests/+page.svelte", + import.meta.url, + ), + ); + assertEquals(list.includes("summarySourceReviewStatus(mergeRequest)"), true); + assertEquals(list.includes("Integration evidence error:"), true); + const detail = await Deno.readTextFile( + new URL( + "../../routes/w/[workspaceId]/merge-requests/[mergeRequestId]/+page.svelte", + import.meta.url, + ), + ); + assertEquals(detail.includes("Recorded integration approval"), true); + assertEquals(detail.includes("Live source revision"), true); + assertEquals(detail.includes("Source observation error"), true); +}); + +Deno.test("merged detail uses backend validated immutable evidence even with a partial thread", () => { + const detail: MergeRequestDetail = { + merge_request_id: "mr-1", + workspace_id: "workspace-a", + repository_key: "main", + ticket_ids: [], + linked_tickets: [], + selector_from: "work/T-1", + selector_to: "develop", + state: "merged", + created_at: summary.updated_at, + updated_at: summary.updated_at, + source: { + status: "known", + ref: "source-1", + observed_at: summary.updated_at, + }, + target: { + status: "known", + ref: "target-2", + observed_at: summary.updated_at, + }, + thread: [{ + kind: "merge", + event_id: "merge-1", + sequence: 3, + created_at: summary.updated_at, + operation_id: "operation-1", + approval_event_id: "approval-1", + approved_source_ref: "source-1", + target_ref_before: "target-1", + target_ref_after: "target-2", + strategy: "fast_forward", + resolution: "none", + merged_by: { runtime_id: "runtime-a", worker_id: "worker-a" }, + }], + }; + assertEquals( + sourceReviewFreshness(detail), + "Recorded integration approval approval-1 for immutable merged source source-1.", + ); + assertEquals( + sourceReviewFreshness({ ...detail, thread: [] }), + "Recorded integration approval for immutable merged source source-1.", + ); + assertEquals( + sourceReviewFreshness({ + ...detail, + source: { + ...detail.source, + status: "unknown", + diagnostic: { code: "approval_revoked", message: "Revoked" }, + }, + }), + "Immutable integration evidence unavailable: approval_revoked.", + ); + assertEquals( + sourceReviewFreshness({ + ...detail, + source: { ...detail.source, status: "unknown", ref: null }, + }), + "Immutable integration evidence unavailable: no validated persisted source.", + ); +}); diff --git a/web/workspace/src/lib/workspace/merge-request-status.ts b/web/workspace/src/lib/workspace/merge-request-status.ts index 03a584e1b..d3b186bdd 100644 --- a/web/workspace/src/lib/workspace/merge-request-status.ts +++ b/web/workspace/src/lib/workspace/merge-request-status.ts @@ -1,3 +1,7 @@ +import type { + TicketEvidenceSummary, + TicketMergeRequestSummary, +} from "#lib/generated/ticket-api.ts"; import type { MergeRequestDetail, MergeRequestThreadEvent, @@ -29,6 +33,24 @@ function requestHasTerminalOutcome( export function sourceReviewFreshness( mergeRequest: MergeRequestDetail, ): string { + if (mergeRequest.state === "merged") { + // The Backend validates the full persisted result before paging the thread. + // A merge event in a page alone is not evidence of a valid approval. + if (mergeRequest.source.diagnostic) { + return `Immutable integration evidence unavailable: ${mergeRequest.source.diagnostic.code}.`; + } + const source = mergeRequest.source.ref; + if (mergeRequest.source.status !== "known" || !source) { + return "Immutable integration evidence unavailable: no validated persisted source."; + } + const merge = [...mergeRequest.thread].reverse().find( + (event): event is Extract => + event.kind === "merge", + ); + return merge + ? `Recorded integration approval ${merge.approval_event_id} for immutable merged source ${source}.` + : `Recorded integration approval for immutable merged source ${source}.`; + } const source = mergeRequest.source.ref; if (!source) return "Source review unavailable: selector_from is unresolved."; @@ -70,6 +92,49 @@ export function sourceReviewFreshness( return `Fresh source review required: no effective verdict exists for ${source}.`; } +// Source/integration approval can survive rescope; only this exact revision +// and linked-result attestation approves the current Ticket requirements. +export function currentRequirementApprovalStatus( + evidence: TicketEvidenceSummary, +): string { + return evidence.review_after_rescope ? "approved" : "not established"; +} + +export function summarySourceReviewStatus( + mergeRequest: TicketMergeRequestSummary, +): string { + const source = mergeRequest.current_subject_ref; + if (mergeRequest.state === "merged") { + if (!source) return "Immutable merged source unavailable."; + if ( + mergeRequest.integration_evidence_error !== null || + mergeRequest.review_status !== "approved" || + mergeRequest.review_subject_ref !== source + ) { + return `Integration approval unavailable for immutable merged source ${source}.`; + } + return `Integration approval recorded for immutable merged source ${source}.`; + } + + const observation = mergeRequest.source_ref_observation; + if (observation.status === "unavailable") { + return `Live source observation unavailable: ${observation.code}.`; + } + if (observation.status === "not_required") { + return "Live source observation not required."; + } + if (!source) { + return "Live source review unavailable: no source ref was observed."; + } + if (mergeRequest.review_subject_ref === source) { + return `Live source review: ${mergeRequest.review_status} for exact ref ${source}.`; + } + if (mergeRequest.review_subject_ref) { + return `Fresh live source review required: selector_from moved from ${mergeRequest.review_subject_ref} to ${source}.`; + } + return `Fresh live source review required: no effective verdict exists for ${source}.`; +} + export function targetIntegrationStatus( mergeRequest: MergeRequestDetail, ): string { diff --git a/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts b/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts new file mode 100644 index 000000000..d2e8fa0f6 --- /dev/null +++ b/web/workspace/src/lib/workspace/sidebar/worker-launch.test-fixtures.ts @@ -0,0 +1,39 @@ +import type { WorkerLaunchOptionsResponse } from "./types.ts"; + +export function emptyLaunchOptions( + workspaceId: string, +): WorkerLaunchOptionsResponse { + return { + workspace_id: workspaceId, + runtimes: [ + { + runtime_id: "embedded", + display_name: "Embedded", + status: "active", + worker_creation_available: true, + built_in: true, + working_directory_required: false, + diagnostics: [], + }, + { + runtime_id: "remote", + display_name: "Remote", + status: "active", + worker_creation_available: true, + built_in: false, + working_directory_required: true, + diagnostics: [], + }, + ], + default_profile: "builtin:companion", + profiles: [{ + id: "builtin:companion", + label: "Companion", + description: "Conversation", + feature_connections: { subjektiv: false }, + }], + repositories: [], + working_directories: [], + diagnostics: [], + }; +} diff --git a/web/workspace/src/lib/workspace/styles/workspace-catalog.css b/web/workspace/src/lib/workspace/styles/workspace-catalog.css index 487eb10c5..7fc9c6467 100644 --- a/web/workspace/src/lib/workspace/styles/workspace-catalog.css +++ b/web/workspace/src/lib/workspace/styles/workspace-catalog.css @@ -41,7 +41,7 @@ .workspace-catalog-grid { display: grid; - grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr)); gap: 1rem; margin-top: 1rem; } @@ -109,7 +109,7 @@ .workspace-create-panel { padding: 1.5rem; display: grid; - grid-template-columns: minmax(220px, 0.7fr) minmax(320px, 1.3fr); + grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr)); gap: 2rem; } @@ -136,6 +136,32 @@ font: inherit; } + .workspace-create-panel .workspace-repository-toggle { + display: flex; + align-items: center; + gap: var(--space-2); + color: var(--text); + } + + .workspace-repository-fields { + display: grid; + gap: var(--space-4); + min-width: 0; + } + + .workspace-create-panel input:not([type="checkbox"]) { + width: 100%; + } + + .workspace-create-row { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); + } + + .workspace-create-panel label { + min-width: 0; + } + .workspace-create-row > label { flex: 1; } diff --git a/web/workspace/src/lib/workspace/tickets/merge-request-resources.test.ts b/web/workspace/src/lib/workspace/tickets/merge-request-resources.test.ts index 4fb5681ae..4c6b0ec7f 100644 --- a/web/workspace/src/lib/workspace/tickets/merge-request-resources.test.ts +++ b/web/workspace/src/lib/workspace/tickets/merge-request-resources.test.ts @@ -80,7 +80,16 @@ Deno.test("Merge Request UI separates source review freshness from target integr detailPage.includes("targetIntegrationStatus"), "MR detail page does not render authority status projections", ); - for (const source of [`${detailPage}\n${statusProjection}`, ticketPage]) { + assert( + ticketPage.includes("summarySourceReviewStatus(mergeRequest)"), + "Ticket page does not render the source observation/integration evidence projection", + ); + for ( + const source of [ + `${detailPage}\n${statusProjection}`, + `${ticketPage}\n${statusProjection}`, + ] + ) { assert( source.includes("Fresh source review required"), "missing source-review freshness diagnostic", diff --git a/web/workspace/src/routes/+page.svelte b/web/workspace/src/routes/+page.svelte index 11ddae502..5eb48a545 100644 --- a/web/workspace/src/routes/+page.svelte +++ b/web/workspace/src/routes/+page.svelte @@ -17,6 +17,7 @@ let creating = $state(false); let creationError = $state(null); let displayName = $state(""); + let includeRepository = $state(false); let repositoryKey = $state(""); let repositoryUri = $state(""); let defaultRef = $state(""); @@ -46,23 +47,17 @@ event.preventDefault(); if (creating) return; const normalized = { - displayName: displayName.trim(), - repositoryUri: repositoryUri.trim(), - repositoryKey: repositoryKey.trim(), - defaultRef: defaultRef.trim(), + display_name: displayName.trim(), + repository: includeRepository ? { + repository_key: repositoryKey.trim(), + uri: repositoryUri.trim(), + default_ref: defaultRef.trim() || null, + } : null, }; const signature = JSON.stringify(normalized); const request = lastSubmission?.signature === signature ? lastSubmission.request - : { - operation_key: createOperationKey(), - display_name: normalized.displayName, - repository: { - repository_key: normalized.repositoryKey, - uri: normalized.repositoryUri, - default_ref: normalized.defaultRef || null, - }, - }; + : { operation_key: createOperationKey(), ...normalized }; lastSubmission = { signature, request }; creating = true; creationError = null; @@ -154,29 +149,38 @@

New team space

Create Workspace

-

- Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH. -

+

You can add repositories later in Workspace Settings.

-