From ba3ebaa68c8b68361da032892d7f26c281af121d Mon Sep 17 00:00:00 2001 From: Hare Date: Sun, 11 Oct 2026 01:41:46 +0900 Subject: [PATCH] feat: separate Ticket case state and immutable closure judgments --- crates/client/src/workspace_product.rs | 515 ++-- crates/manifest/src/profile.rs | 2 +- crates/merge-request/src/lib.rs | 30 +- crates/server-api/src/lib.rs | 369 ++- .../src/repository_openapi_typescript.rs | 63 +- crates/ticket/src/case_schema.sql | 32 + crates/ticket/src/latest_schema.sql | 33 + crates/ticket/src/lib.rs | 2506 ++++------------- crates/ticket/src/sqlite_schema.rs | 156 +- crates/ticket/src/tool.rs | 861 ++---- crates/ticket/tests/case.rs | 800 ++++++ crates/ticket/tests/fixtures/schema7.sql | 134 + crates/ticket/tests/migration.rs | 252 ++ .../src/feature/builtin/orchestration.rs | 60 +- .../feature/builtin/resource_projection.rs | 99 +- crates/worker/src/feature/builtin/ticket.rs | 1320 +++------ crates/worker/src/prompt/catalog.rs | 2 +- crates/worker/src/prompt/system.rs | 8 +- crates/workspace-server/src/authority.rs | 211 +- crates/workspace-server/src/latest_schema.sql | 111 + .../src/legacy_evidence_column_tests.rs | 2 +- crates/workspace-server/src/retention.rs | 264 +- crates/workspace-server/src/server.rs | 981 +++---- .../src/server/tests/api_logging_tests.rs | 34 +- .../src/server/tests/ticket_evidence_tests.rs | 446 ++- .../server/tests/ticket_worker_claim_tests.rs | 57 +- .../tests/ticket_worker_launch_tests.rs | 242 +- crates/workspace-server/src/store.rs | 818 +++++- .../workspace-server/src/workdir_removal.rs | 202 +- .../src/workspace_deletion.rs | 217 +- crates/yoi/src/ticket_cli.rs | 927 +++++- docs/development/ticket-case-state.md | 53 + ...10-10-subworker-cwd-attachment-boundary.md | 10 + openapi/server-api.json | 1662 ++++------- resources/prompts/common/merge-request.md | 2 +- resources/prompts/common/tickets.md | 2 +- resources/prompts/role/coder.md | 2 +- resources/prompts/role/orchestrator.md | 4 +- resources/prompts/role/ticket-worker.md | 2 +- tools/state-contract-name-exceptions.json | 4 +- web/workspace/deno.json | 2 +- .../src/lib/generated/companion-api.ts | 2 +- .../src/lib/generated/repository-api.ts | 2 +- .../src/lib/generated/runtime-api.ts | 2 +- web/workspace/src/lib/generated/ticket-api.ts | 36 +- .../src/lib/generated/workdir-api.ts | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../lib/workspace/api/ticket-browser.test.ts | 163 +- .../src/lib/workspace/api/ticket-browser.ts | 219 +- .../src/lib/workspace/api/ticket-case.test.ts | 116 + .../src/lib/workspace/api/ticket-case.ts | 47 + .../console/worker-console.ui.test.ts | 11 +- .../workspace/home/dashboard.test-fixtures.ts | 30 +- .../src/lib/workspace/home/dashboard.ts | 30 +- .../workspace/merge-request-status.test.ts | 16 +- .../src/lib/workspace/styles/tickets.css | 9 +- .../workspace/tickets/ticket-panel.test.ts | 50 +- .../src/lib/workspace/tickets/ticket-panel.ts | 41 +- .../src/routes/w/[workspaceId]/+page.svelte | 4 +- .../w/[workspaceId]/home.browser.spec.ts | 12 +- .../w/[workspaceId]/tickets/+page.svelte | 4 +- .../routes/w/[workspaceId]/tickets/+page.ts | 21 +- .../tickets/[ticketId]/+page.svelte | 147 +- .../test/ticket-detail-route-reuse.test.ts | 1 + web/workspace/vite.config.ts | 5 + 65 files changed, 7625 insertions(+), 6844 deletions(-) create mode 100644 crates/ticket/src/case_schema.sql create mode 100644 crates/ticket/tests/case.rs create mode 100644 crates/ticket/tests/fixtures/schema7.sql create mode 100644 crates/ticket/tests/migration.rs create mode 100644 docs/development/ticket-case-state.md create mode 100644 docs/report/2026-10-10-subworker-cwd-attachment-boundary.md create mode 100644 web/workspace/src/lib/workspace/api/ticket-case.test.ts create mode 100644 web/workspace/src/lib/workspace/api/ticket-case.ts diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 32411e020..33570f44a 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -20,6 +20,7 @@ use ticket::{ use crate::{BackendApiClient, BackendWorkspaceClientError}; const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000; +const UNSUPPORTED_TICKET_STATE_CHANGE: &str = "unsupported legacy Ticket state/close operation; use TicketBackend::update_case with caller-supplied content digest, case state, closure operation key, and replay key (CAS); case updates do not start or stop work"; /// Workspace-scoped Backend client for Ticket and Objective product state. /// @@ -155,76 +156,24 @@ impl BackendWorkspaceProductClient { }) } - fn decision_from_state_change( - &self, - id: &TicketIdOrSlug, - change: &TicketStateChange, - ) -> Result { - let current = self.show_ticket(id)?; - let parse = |state: &str| { - ticket::TicketWorkflowState::parse(state).ok_or_else(|| { - BackendWorkspaceClientError::InvalidTarget(format!("invalid Ticket state {state}")) - }) - }; - Ok(ticket::TicketStateUpdate { - operation_key: uuid::Uuid::now_v7().to_string(), - expected_content_digest: ticket::ticket_content_digest(¤t), - expected_state: parse(&change.from)?, - state: parse(&change.to)?, - reason: if change.body.as_str().is_empty() { - change.reason.clone() - } else { - format!("{}\n\n{}", change.reason, change.body.as_str()) - }, - references: change.references.clone(), - author: change.author.clone(), - }) - } - pub fn set_ticket_workflow_state( &self, - id: &TicketIdOrSlug, - change: &TicketStateChange, + _id: &TicketIdOrSlug, + _change: &TicketStateChange, ) -> Result<(), BackendWorkspaceClientError> { - let change = self.decision_from_state_change(id, change)?; - let workspace_id = self.workspace_id.clone(); - let id = ticket_reference(id); - self.generated(move |client| async move { - client - .ticket_workflow_state_set( - workspace_id, - id, - server_api::TicketStateChangeRequest(change), - ) - .await - }) + Err(BackendWorkspaceClientError::InvalidTarget( + UNSUPPORTED_TICKET_STATE_CHANGE.into(), + )) } pub fn close_ticket( &self, - id: &TicketIdOrSlug, - resolution: &MarkdownText, + _id: &TicketIdOrSlug, + _resolution: &MarkdownText, ) -> Result<(), BackendWorkspaceClientError> { - let current = self.show_ticket(id)?; - let request = ticket::TicketCompletion { - operation_key: uuid::Uuid::now_v7().to_string(), - expected_content_digest: ticket::ticket_content_digest(¤t), - expected_state: current.meta.workflow_state, - reason: resolution.as_str().to_string(), - references: Vec::new(), - author: None, - }; - let workspace_id = self.workspace_id.clone(); - let id = ticket_reference(id); - self.generated(move |client| async move { - client - .ticket_close_record( - workspace_id, - id, - server_api::TicketCloseRecordRequest(request), - ) - .await - }) + Err(BackendWorkspaceClientError::InvalidTarget( + UNSUPPORTED_TICKET_STATE_CHANGE.into(), + )) } pub fn add_ticket_relation( @@ -664,24 +613,12 @@ impl TicketBackend for BackendWorkspaceProductClient { fn add_state_changed( &self, - id: TicketIdOrSlug, - change: TicketStateChange, + _id: TicketIdOrSlug, + _change: TicketStateChange, ) -> ticket::Result<()> { - let change = self - .decision_from_state_change(&id, &change) - .map_err(ticket_client_error)?; - let workspace_id = self.workspace_id.clone(); - let id = ticket_reference(&id); - self.generated(move |client| async move { - client - .ticket_state_change_add( - workspace_id, - id, - server_api::TicketStateChangeRequest(change), - ) - .await - }) - .map_err(ticket_client_error) + Err(TicketError::Conflict( + UNSUPPORTED_TICKET_STATE_CHANGE.into(), + )) } fn add_intake_summary( @@ -703,59 +640,31 @@ impl TicketBackend for BackendWorkspaceProductClient { .map_err(ticket_client_error) } - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> ticket::Result<()> { - if field != "state" { - return Err(TicketError::Conflict( - "unsupported Ticket state field".into(), - )); - } - let change = self - .decision_from_state_change(&id, &change) - .map_err(ticket_client_error)?; - let workspace_id = self.workspace_id.clone(); - let id = ticket_reference(&id); - let field = field.to_string(); - self.generated(move |client| async move { - client - .ticket_state_field_set( - workspace_id, - id, - field, - server_api::TicketStateChangeRequest(change), - ) - .await - }) - .map_err(ticket_client_error) - } - - fn set_workflow_state( - &self, - id: TicketIdOrSlug, - change: TicketStateChange, - ) -> ticket::Result<()> { - self.set_ticket_workflow_state(&id, &change) - .map_err(ticket_client_error) - } - - fn update_state( + fn update_case( &self, ticket: &str, - request: ticket::TicketStateUpdate, + request: ticket::TicketCaseUpdate, ) -> ticket::Result { let workspace_id = self.workspace_id.clone(); let id = ticket.to_owned(); + // Actor identity belongs to authenticated Backend authority, not the caller. + let request = server_api::TicketCaseUpdateRequest { + expected_content_digest: request.expected_content_digest, + expected_state: request.expected_state, + expected_closure_operation_key: request.expected_closure_operation_key, + operation_key: request.operation_key, + action: request.action, + explanation: request.explanation, + references: request.references, + }; self.generated(move |client| async move { client - .ticket_state_update( - workspace_id, - id, - server_api::TicketStateUpdateRequest(request), - ) + .ticket_case_update(workspace_id.clone(), id.clone(), request) + .await?; + // The case endpoint returns only a summary. Fetch the authoritative + // full record rather than fabricating body, events, or case history. + client + .ticket_record_get(workspace_id, id) .await .map(|response| response.0) }) @@ -795,11 +704,6 @@ impl TicketBackend for BackendWorkspaceProductClient { .map_err(ticket_client_error) } - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> { - self.close_ticket(&id, &resolution) - .map_err(ticket_client_error) - } - fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -941,6 +845,33 @@ mod tests { use super::*; + fn read_http_request(stream: &mut std::net::TcpStream) -> String { + stream + .set_read_timeout(Some(std::time::Duration::from_secs(5))) + .unwrap(); + let mut request = Vec::new(); + let mut buffer = [0_u8; 4_096]; + loop { + let bytes = stream.read(&mut buffer).unwrap(); + assert_ne!(bytes, 0, "incomplete HTTP request"); + request.extend_from_slice(&buffer[..bytes]); + if let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") { + let headers = std::str::from_utf8(&request[..header_end]).unwrap(); + let content_length = headers + .lines() + .find_map(|line| { + let (name, value) = line.split_once(':')?; + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().unwrap()) + }) + .unwrap_or(0); + if request.len() >= header_end + 4 + content_length { + return String::from_utf8(request).unwrap(); + } + } + } + } + fn one_response_server( status: &str, body: &str, @@ -952,11 +883,7 @@ mod tests { let (sender, receiver) = mpsc::channel(); let handle = thread::spawn(move || { let (mut stream, _) = listener.accept().unwrap(); - let mut request = vec![0_u8; 8_192]; - let bytes = stream.read(&mut request).unwrap(); - sender - .send(String::from_utf8_lossy(&request[..bytes]).to_string()) - .unwrap(); + sender.send(read_http_request(&mut stream)).unwrap(); write!( stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", @@ -980,11 +907,7 @@ mod tests { let handle = thread::spawn(move || { for (status, body) in responses { let (mut stream, _) = listener.accept().unwrap(); - let mut request = vec![0_u8; 16_384]; - let bytes = stream.read(&mut request).unwrap(); - sender - .send(String::from_utf8_lossy(&request[..bytes]).to_string()) - .unwrap(); + sender.send(read_http_request(&mut stream)).unwrap(); write!( stream, "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", @@ -997,114 +920,228 @@ mod tests { } #[test] - fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() { + fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + listener.set_nonblocking(true).unwrap(); + let client = BackendWorkspaceProductClient::new_with_access_token( + format!("http://{}", listener.local_addr().unwrap()), + "workspace-a", + "token", + ) + .unwrap(); + let id = TicketIdOrSlug::Query("T-739".into()); + let change = TicketStateChange::new("open", "closed", "Judgment", "Explanation"); + let errors = [ + client + .set_ticket_workflow_state(&id, &change) + .unwrap_err() + .to_string(), + client + .close_ticket(&id, &MarkdownText::new("Withdrawn")) + .unwrap_err() + .to_string(), + TicketBackend::add_state_changed(&client, id, change) + .unwrap_err() + .to_string(), + ]; + for error in errors { + assert!( + error.contains("unsupported legacy Ticket state/close operation"), + "{error}" + ); + assert!(error.contains("TicketBackend::update_case"), "{error}"); + assert!(error.contains("caller-supplied"), "{error}"); + assert!(error.contains("CAS"), "{error}"); + } + assert_eq!( + listener.accept().unwrap_err().kind(), + std::io::ErrorKind::WouldBlock + ); + } + + #[test] + fn serialized_update_case_forwards_tagged_actions_and_returns_full_backend_record() { + for action in [ + serde_json::json!({"action": "close", "reason": "completed"}), + serde_json::json!({"action": "close", "reason": {"other": "Custom judgment"}}), + serde_json::json!({"action": "correct_closure", "reason": "duplicate"}), + serde_json::json!({"action": "reopen"}), + ] { + // SQLite only supplies hermetic response fixtures, never a client fallback. + let temp = tempfile::tempdir().unwrap(); + let backend = + ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") + .unwrap(); + let mut input = NewTicket::new("Full case record"); + input.body = MarkdownText::new("Full body absent from the endpoint summary"); + let reference = backend.create(input).unwrap(); + let mut snapshot = backend.show(reference.id.clone().into()).unwrap(); + if action["action"] != "close" { + snapshot = backend + .update_case( + &reference.id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&snapshot), + expected_state: ticket::TicketState::Open, + expected_closure_operation_key: None, + operation_key: "initial-close".into(), + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + explanation: "Initial judgment".into(), + references: Vec::new(), + author: Some("authenticated-actor".into()), + }, + ) + .unwrap(); + } + let expected_request = serde_json::json!({ + "expected_content_digest": ticket::ticket_content_digest(&snapshot), + "expected_state": snapshot.meta.workflow_state.as_str(), + "expected_closure_operation_key": snapshot.closure_operation_key, + "operation_key": "caller-case-key", + "action": action, + "explanation": "Caller judgment, not work progress", + "references": [{"kind": "ticket", "target": "T-738"}], + }); + let mut domain_request = expected_request.clone(); + domain_request["author"] = serde_json::json!("caller-cannot-impersonate"); + let operation = + serde_json::from_value::(serde_json::json!({ + "operation": "update_case", + "ticket": reference.id, + "request": domain_request, + })) + .unwrap(); + let mut fixture_request: ticket::TicketCaseUpdate = + serde_json::from_value(expected_request.clone()).unwrap(); + fixture_request.author = Some("authenticated-actor".into()); + let expected = backend.update_case(&reference.id, fixture_request).unwrap(); + let summary = + serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap(); + let full_record = serde_json::to_string(&expected).unwrap(); + let (base_url, requests, handle) = + response_sequence_server(vec![("200 OK", &summary), ("200 OK", &full_record)]); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-token", + ) + .unwrap(); + let result = ticket::execute_ticket_backend_operation(&client, operation).unwrap(); + let ticket::TicketBackendOperationResult::Ticket(actual) = result else { + panic!("case operation must return a full Ticket"); + }; + assert_eq!(actual, expected); + let post = requests.recv().unwrap(); + assert!( + post.starts_with(&format!( + "POST /workspaces/workspace-a/tickets/{}/case ", + reference.id, + )), + "{post}" + ); + assert!( + post.contains("authorization: Bearer test-token\r\n"), + "{post}" + ); + let body: serde_json::Value = + serde_json::from_str(post.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body, expected_request); + assert!(body.get("author").is_none()); + assert!(requests.recv().unwrap().starts_with(&format!( + "GET /api/w/workspace-a/tickets/{}/record ", + reference.id, + ))); + handle.join().unwrap(); + } + } + + fn case_request() -> ticket::TicketCaseUpdate { + ticket::TicketCaseUpdate { + operation_key: "caller-case-key".into(), + expected_content_digest: "caller-content-digest".into(), + expected_state: ticket::TicketState::Closed, + expected_closure_operation_key: Some("last-close-key".into()), + action: ticket::TicketCaseAction::Reopen, + explanation: "Caller reopening judgment".into(), + references: Vec::new(), + author: None, + } + } + + #[test] + fn case_update_rejection_preserves_caller_cas_without_local_fallback() { + for (status, body, expected_error) in [ + ( + "409 Conflict", + r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#, + "409", + ), + ( + "403 Forbidden", + r#"{"error":"forbidden","message":"not authorized","diagnostics":[]}"#, + "403", + ), + ] { + let (base_url, requests, handle) = one_response_server(status, body); + let client = BackendWorkspaceProductClient::new_with_access_token( + base_url, + "workspace-a", + "test-token", + ) + .unwrap(); + let error = TicketBackend::update_case(&client, "T-739", case_request()).unwrap_err(); + assert!( + error.to_string().contains(expected_error), + "{error}; response {status}: {body}" + ); + let request = requests.recv().unwrap(); + assert!( + request.starts_with("POST /workspaces/workspace-a/tickets/T-739/case "), + "{request}" + ); + let body: serde_json::Value = + serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); + assert_eq!(body["expected_content_digest"], "caller-content-digest"); + assert_eq!(body["expected_state"], "closed"); + assert_eq!(body["expected_closure_operation_key"], "last-close-key"); + assert_eq!(body["operation_key"], "caller-case-key"); + assert_eq!(body["action"], serde_json::json!({"action": "reopen"})); + assert!(!error.to_string().contains("test-token")); + handle.join().unwrap(); + assert!(requests.try_recv().is_err()); + } + } + + #[test] + fn successful_case_update_with_failed_show_does_not_fabricate_full_ticket() { let temp = tempfile::tempdir().unwrap(); let backend = ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") .unwrap(); - let reference = backend.create(NewTicket::new("Adapter snapshot")).unwrap(); - let snapshot = backend.show(reference.id.clone().into()).unwrap(); - let body = serde_json::to_string(&snapshot).unwrap(); - let (base_url, requests, handle) = - response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]); - let client = - BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token") - .unwrap(); - client - .set_ticket_workflow_state( - &reference.id.clone().into(), - &TicketStateChange::new("ready", "done", "Judgment", "Results in the thread"), - ) - .unwrap(); - assert!(requests.recv().unwrap().starts_with("GET ")); - let request = requests.recv().unwrap(); - let body: serde_json::Value = - serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); - assert_eq!( - body["expected_content_digest"], - ticket::ticket_content_digest(&snapshot) - ); - assert_eq!(body["expected_state"], "ready"); - assert_eq!(body["state"], "done"); - assert!(!body["operation_key"].as_str().unwrap().is_empty()); - handle.join().unwrap(); - } - - #[test] - fn legacy_close_adapter_forwards_snapshot_cas_and_resolution_as_judgment() { - let temp = tempfile::tempdir().unwrap(); - let backend = - ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") - .unwrap(); - let reference = backend - .create(NewTicket::new("Close adapter snapshot")) - .unwrap(); - let snapshot = backend.show(reference.id.clone().into()).unwrap(); - let body = serde_json::to_string(&snapshot).unwrap(); - let (base_url, requests, handle) = - response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]); - let client = - BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token") - .unwrap(); - client - .close_ticket( - &reference.id.into(), - &MarkdownText::new("The request was withdrawn"), - ) - .unwrap(); - assert!(requests.recv().unwrap().starts_with("GET ")); - let request = requests.recv().unwrap(); - let body: serde_json::Value = - serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); - assert_eq!( - body["expected_content_digest"], - ticket::ticket_content_digest(&snapshot) - ); - assert_eq!(body["expected_state"], "planning"); - assert_eq!(body["reason"], "The request was withdrawn"); - assert!(!body["operation_key"].as_str().unwrap().is_empty()); - handle.join().unwrap(); - } - - #[test] - fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() { - let (base_url, request, handle) = one_response_server( - "409 Conflict", - r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#, - ); + let reference = backend.create(NewTicket::new("Summary only")).unwrap(); + let summary = + serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap(); + let (base_url, requests, handle) = response_sequence_server(vec![ + ("200 OK", &summary), + ( + "404 Not Found", + r#"{"error":"ticket_not_found","message":"full record unavailable","diagnostics":[]}"#, + ), + ]); let client = BackendWorkspaceProductClient::new_with_access_token( base_url, "workspace-a", "test-token", ) .unwrap(); - let result = TicketBackend::complete( - &client, - "T-718", - ticket::TicketCompletion { - operation_key: "research-done".into(), - expected_content_digest: "ticket:1".into(), - expected_state: ticket::TicketWorkflowState::Planning, - reason: "Research findings recorded".into(), - references: Vec::new(), - author: None, - }, - ); - assert!(result.is_err()); - let request = request.recv().unwrap(); - assert!( - request.starts_with("POST /api/w/workspace-a/tickets/T-718/state-update "), - "{request}" - ); - let body: serde_json::Value = - serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap(); - assert_eq!(body["state"], "done"); - assert_eq!(body["operation_key"], "research-done"); - assert_eq!(body["expected_content_digest"], "ticket:1"); - assert_eq!(body["expected_state"], "planning"); - assert_eq!(body["reason"], "Research findings recorded"); - assert!(body.get("merge_request_ids").is_none()); + let error = TicketBackend::update_case(&client, &reference.id, case_request()).unwrap_err(); + assert!(error.to_string().contains("404"), "{error}"); + assert!(requests.recv().unwrap().starts_with("POST /workspaces/")); + assert!(requests.recv().unwrap().starts_with("GET /api/w/")); handle.join().unwrap(); + assert!(requests.try_recv().is_err()); } #[test] @@ -1414,12 +1451,12 @@ mod tests { assert_eq!( ticket_list_state_query(&TicketListQuery { state: TicketStateSelector::States( - [TicketListState::Ready, TicketListState::InProgress] + [TicketListState::Open, TicketListState::Closed] .into_iter() .collect(), ), }), - "ready,inprogress" + "open,closed" ); } diff --git a/crates/manifest/src/profile.rs b/crates/manifest/src/profile.rs index 454643df7..d0cc45a03 100644 --- a/crates/manifest/src/profile.rs +++ b/crates/manifest/src/profile.rs @@ -1329,7 +1329,7 @@ mod tests { assert_eq!(manifest.engine.instruction, "role.ticket_worker"); // enabled supplies ShowTicket; thread supplies TicketComment, - // TicketWorkflowState and CompleteTicket without broad workflow access. + // TicketCaseUpdate without broad workflow access. let feature = &manifest.feature; assert!(feature.ticket.enabled); assert!(feature.ticket.thread); diff --git a/crates/merge-request/src/lib.rs b/crates/merge-request/src/lib.rs index 21054a748..aace80765 100644 --- a/crates/merge-request/src/lib.rs +++ b/crates/merge-request/src/lib.rs @@ -1159,18 +1159,9 @@ impl MergeRequestStore { "approval predates the current Ticket digest; fresh review is required".into(), )); } - let state: Option = connection - .query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", - params![mr.workspace_id, i.ticket_id], - |row| row.get(0), - ) - .optional()?; - if state.as_deref() != Some("inprogress") { - return Err(MergeRequestError::Conflict( - "Ticket must be inprogress".into(), - )); - } + // Case openness is not an integration phase. Exact source approval, + // current Ticket content and active assignment fencing above remain + // the independent MR integration guards. Ok(()) } @@ -1239,18 +1230,9 @@ impl MergeRequestStore { "approval predates the current Ticket digest; fresh review is required".into(), )); } - let state: Option = transaction - .query_row( - "SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2", - params![mr.workspace_id, i.ticket_id], - |row| row.get(0), - ) - .optional()?; - if state.as_deref() != Some("inprogress") { - return Err(MergeRequestError::Conflict( - "Ticket must be inprogress".into(), - )); - } + // Case openness is not an integration phase. Exact source approval, + // current Ticket content and active assignment fencing above remain + // the independent MR integration guards. let issued_grants = { let mut statement = transaction.prepare( "SELECT request_event_id,subject_ref,capability_token diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index d5758ad75..6c33ad521 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -306,14 +306,11 @@ impl_openapi_schema!( SkillDetailResponse, SkillActivationResponse, BrowserAppendTicketEventRequest, - BrowserCloseTicketRequest, BrowserEditTicketRequest, BrowserQueueTicketRequest, - BrowserTransitionTicketStateRequest, CancelTicketImplementationRequest, ClearTicketRoleAssignmentQuery, CompleteMergeRequestRequest, - CompleteTicketRequest, CreateTicketOrchestrationPlanRequest, CreateTicketRecordRequest, CreateTicketRelationRequest, @@ -347,9 +344,10 @@ impl_openapi_schema!( SetTicketRoleAssignmentRequest, SubmitMergeRequestReviewRequest, TextResponse, - TicketCloseRecordRequest, TicketDependencyCheckResponse, TicketDetail, + TicketCaseUpdateRequest, + TicketSummary, TicketDoctorResponse, TicketIntakeSummaryRequest, TicketListHttpQuery, @@ -372,8 +370,6 @@ impl_openapi_schema!( TicketRoleAssignmentMutationResponse, TicketRoleAssignmentsResponse, TicketShowRequest, - TicketStateChangeRequest, - TicketStateUpdateRequest, TicketSummarySearchQuery, TicketThreadEventRequest, RuntimeManagementApiError, @@ -3051,14 +3047,6 @@ pub trait ServerApi { #[path] id: String, #[body] request: TicketThreadEventRequest, ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_state_change_add( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: TicketStateChangeRequest, - ) -> Result<(), RepositoryApiError>; #[post("/api/w/{workspace_id}/tickets/{id}/intake-summaries", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_intake_summary_add( &self, @@ -3067,31 +3055,6 @@ pub trait ServerApi { #[path] id: String, #[body] request: TicketIntakeSummaryRequest, ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_state_field_set( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[path] field: String, - #[body] request: TicketStateChangeRequest, - ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_workflow_state_set( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: TicketStateChangeRequest, - ) -> Result<(), RepositoryApiError>; - #[post("/api/w/{workspace_id}/tickets/{id}/state-update", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_state_update( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: TicketStateUpdateRequest, - ) -> Result; #[post("/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_mark_ready_record( &self, @@ -3189,22 +3152,14 @@ pub trait ServerApi { #[path] id: String, #[body] request: CompleteMergeRequestRequest, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_complete( + #[post("/workspaces/{workspace_id}/tickets/{ticket_id}/case", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_case_update( &self, #[extension] context: ServerRequestContext, #[path] workspace_id: String, - #[path] id: String, - #[body] request: CompleteTicketRequest, - ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_close_record( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: TicketCloseRecordRequest, - ) -> Result<(), RepositoryApiError>; + #[path] ticket_id: String, + #[body] request: TicketCaseUpdateRequest, + ) -> Result; #[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn ticket_relation_view( &self, @@ -3292,15 +3247,6 @@ pub trait ServerApi { #[body] request: CancelTicketImplementationRequest, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_state_transition( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: BrowserTransitionTicketStateRequest, - ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] async fn ticket_ready( &self, @@ -3327,14 +3273,6 @@ pub trait ServerApi { #[body] request: BrowserQueueTicketRequest, ) -> Result; - #[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)] - async fn ticket_close( - &self, - #[extension] context: ServerRequestContext, - #[path] workspace_id: String, - #[path] id: String, - #[body] request: BrowserCloseTicketRequest, - ) -> Result; #[get("/api/objectives", status = 200, error_status = 400, additional_error_statuses = [500], bearer_auth = true, browser_auth = true)] async fn objective_list_alias( &self, @@ -7263,13 +7201,23 @@ pub struct TicketListItemSummary { pub id: String, pub resource_key: String, pub title: String, - pub state: String, + pub state: BrowserTicketState, pub priority: String, pub updated_at: Option, pub queued_by: Option, pub queued_at: Option, pub workspace_action_priority: String, pub record_source: String, + /// Last closure/correction key, retained across reopening for ABA-safe CAS. + pub closure_operation_key: Option, + #[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))] + pub current_closure: Option, + /// Original migration handoff; not another current state authority. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketLegacyWorkflowSnapshot | null") + )] + pub legacy_handoff: Option, } #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7553,20 +7501,49 @@ pub struct TicketEvidenceSummary { pub missing: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] +#[serde(rename_all = "snake_case")] +pub enum TicketAttention { + UnresolvedReview, + MissingCommit, + Blocked, + Unblocked, + AwaitingReview, + UnresolvedChanges, + StaleAfterRescope, + MissingEvidence, +} + +impl TicketAttention { + pub const fn as_str(&self) -> &'static str { + match self { + Self::UnresolvedReview => "unresolved_review", + Self::MissingCommit => "missing_commit", + Self::Blocked => "blocked", + Self::Unblocked => "unblocked", + Self::AwaitingReview => "awaiting_review", + Self::UnresolvedChanges => "unresolved_changes", + Self::StaleAfterRescope => "stale_after_rescope", + Self::MissingEvidence => "missing_evidence", + } + } +} + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] pub struct TicketQueryRequest { pub query: Option, #[serde(default)] - pub states: Vec, + pub states: Vec, #[serde(default)] pub event_kinds: Vec, #[serde(default)] pub evidence: Vec, pub review_status: Option, #[serde(default)] - pub attention: Vec, + pub attention: Vec, pub related_ticket_id: Option, pub relation_kind: Option, pub linked_objective_id: Option, @@ -7585,7 +7562,7 @@ pub struct TicketQueryItem { pub id: String, pub resource_key: String, pub title: String, - pub state: String, + pub state: BrowserTicketState, pub readiness: Option, pub priority: String, pub created_at: Option, @@ -7613,6 +7590,16 @@ pub struct TicketQueryItem { pub merge_requests: Vec, pub merge_request: Option, pub current_worker: Option, + /// Last closure/correction key, retained across reopening for ABA-safe CAS. + pub closure_operation_key: Option, + #[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))] + pub current_closure: Option, + /// Original migration handoff; not another current state authority. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketLegacyWorkflowSnapshot | null") + )] + pub legacy_handoff: Option, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7640,7 +7627,7 @@ pub struct TicketDetail { pub id: String, pub resource_key: String, pub title: String, - pub state: String, + pub state: BrowserTicketState, pub readiness: Option, pub priority: String, pub created_at: Option, @@ -7674,6 +7661,18 @@ pub struct TicketDetail { pub evidence: TicketEvidenceSummary, pub resolution: Option, pub record_source: String, + /// Last closure/correction key, retained across reopening for ABA-safe CAS. + pub closure_operation_key: Option, + #[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))] + pub current_closure: Option, + /// Original migration handoff; not another current state authority. + #[cfg_attr( + feature = "typescript", + ts(type = "TicketLegacyWorkflowSnapshot | null") + )] + pub legacy_handoff: Option, + #[cfg_attr(feature = "typescript", ts(type = "Array"))] + pub closure_history: Vec, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] @@ -7703,6 +7702,34 @@ macro_rules! transparent_ticket_dto { } transparent_ticket_dto!(TicketRecord, ticket::Ticket); +transparent_ticket_dto!(TicketSummary, ticket::TicketSummary); + +/// Authorized case judgment. Author comes from authenticated Workspace authority, +/// never from the request. All three actions share content/state/closure-key CAS. +fn deserialize_case_closure_observation<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::::deserialize(deserializer) +} + +fn case_closure_observation_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema { + generator.subschema_for::>() +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketCaseUpdateRequest { + pub expected_content_digest: String, + pub expected_state: ticket::TicketState, + #[serde(deserialize_with = "deserialize_case_closure_observation")] + #[schemars(required, schema_with = "case_closure_observation_schema")] + pub expected_closure_operation_key: Option, + pub operation_key: String, + pub action: ticket::TicketCaseAction, + pub explanation: String, + #[serde(default)] + pub references: Vec, +} transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef); transparent_ticket_dto!(TicketRecordSummaryList, Vec); transparent_ticket_dto!(TicketDependencyCheckResponse, ticket::TicketDependencyCheck); @@ -7722,10 +7749,7 @@ transparent_ticket_dto!( transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket); transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit); transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent); -transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateUpdate); -transparent_ticket_dto!(TicketStateUpdateRequest, ticket::TicketStateUpdate); transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary); -transparent_ticket_dto!(TicketCloseRecordRequest, ticket::TicketCompletion); transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation); transparent_ticket_dto!( CreateTicketOrchestrationPlanRequest, @@ -7837,15 +7861,29 @@ pub enum BrowserTicketTargetEdit { #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] -pub enum BrowserTicketWorkflowState { - Planning, - Ready, - Queued, - Inprogress, - Done, +pub enum BrowserTicketState { + Open, Closed, } +impl BrowserTicketState { + pub const fn as_str(&self) -> &'static str { + match self { + Self::Open => "open", + Self::Closed => "closed", + } + } +} + +impl From for BrowserTicketState { + fn from(state: ticket::TicketState) -> Self { + match state { + ticket::TicketState::Open => Self::Open, + ticket::TicketState::Closed => Self::Closed, + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(deny_unknown_fields)] @@ -7860,18 +7898,6 @@ pub struct BrowserEditTicketRequest { pub author: Option, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -#[serde(deny_unknown_fields)] -pub struct BrowserTransitionTicketStateRequest { - pub state: BrowserTicketWorkflowState, - pub operation_key: String, - pub expected_content_digest: String, - pub expected_state: BrowserTicketWorkflowState, - pub reason: String, - pub body: Option, -} - #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[cfg_attr(feature = "typescript", derive(ts_rs::TS))] #[serde(rename_all = "snake_case")] @@ -7896,16 +7922,6 @@ pub struct BrowserAppendTicketEventRequest { #[serde(deny_unknown_fields)] pub struct BrowserQueueTicketRequest {} -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[cfg_attr(feature = "typescript", derive(ts_rs::TS))] -#[serde(deny_unknown_fields)] -pub struct BrowserCloseTicketRequest { - pub operation_key: String, - pub expected_content_digest: String, - pub expected_state: BrowserTicketWorkflowState, - pub resolution: String, -} - #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] #[serde(deny_unknown_fields)] pub struct ObjectiveListQuery { @@ -8278,17 +8294,6 @@ pub struct CompleteMergeRequestRequest { pub resolution: ConflictResolution, } -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct CompleteTicketRequest { - pub operation_key: String, - pub expected_content_digest: String, - pub expected_state: ticket::TicketWorkflowState, - pub reason: String, - #[serde(default)] - pub references: Vec, -} - #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)] #[serde(deny_unknown_fields)] pub struct RuntimeManagementApiError { @@ -11550,14 +11555,9 @@ mod tests { ); for (operation_id, method, path) in [ ( - "ticket_state_update", + "ticket_case_update", HttpMethod::Post, - "/api/w/{workspace_id}/tickets/{id}/state-update", - ), - ( - "ticket_complete", - HttpMethod::Post, - "/api/w/{workspace_id}/tickets/{id}/complete", + "/workspaces/{workspace_id}/tickets/{ticket_id}/case", ), ( "ticket_relation_query", @@ -12747,31 +12747,134 @@ mod tests { } #[test] - fn ticket_completion_wire_requires_cas_and_judgment_not_merge_evidence() { + fn ticket_case_wire_requires_cas_and_judgment_without_client_author() { let request = serde_json::json!({ - "operation_key": "investigation-done", + "operation_key": "question-answered", "expected_content_digest": "ticket:1", - "expected_state": "planning", - "reason": "Question answered in the thread" + "expected_state": "open", + "expected_closure_operation_key": null, + "action": {"action": "close", "reason": "completed"}, + "explanation": "Question answered in the thread" }); - let parsed: CompleteTicketRequest = serde_json::from_value(request.clone()).unwrap(); + let parsed: TicketCaseUpdateRequest = serde_json::from_value(request.clone()).unwrap(); assert!(parsed.references.is_empty()); + assert_eq!(parsed.expected_state, ticket::TicketState::Open); for field in [ "operation_key", "expected_content_digest", "expected_state", - "reason", + "expected_closure_operation_key", + "action", + "explanation", ] { let mut missing = request.clone(); missing.as_object_mut().unwrap().remove(field); assert!( - serde_json::from_value::(missing).is_err(), + serde_json::from_value::(missing).is_err(), "{field}" ); } - let mut old = request; - old["requirement_approval_event_id"] = serde_json::json!("not-a-judgment"); - assert!(serde_json::from_value::(old).is_err()); + for (field, value) in [ + ("author", serde_json::json!("untrusted-author")), + ( + "requirement_approval_event_id", + serde_json::json!("not-a-judgment"), + ), + ("expected_state", serde_json::json!("done")), + ] { + let mut rejected = request.clone(); + rejected[field] = value; + assert!( + serde_json::from_value::(rejected).is_err(), + "{field}" + ); + } + } + + #[test] + fn ticket_case_wire_preserves_actions_and_externally_tagged_other_reason() { + for action in [ + serde_json::json!({"action": "close", "reason": "not_planned"}), + serde_json::json!({"action": "reopen"}), + serde_json::json!({"action": "correct_closure", "reason": {"other": "Deferred by user"}}), + ] { + let request = serde_json::json!({ + "operation_key": "case-decision", + "expected_content_digest": "ticket:2", + "expected_state": "closed", + "expected_closure_operation_key": "previous-close", + "action": action, + "explanation": "Explicit user decision", + "references": [{"kind": "ticket", "target": "T-2"}] + }); + let parsed: TicketCaseUpdateRequest = serde_json::from_value(request.clone()).unwrap(); + assert_eq!(serde_json::to_value(parsed).unwrap(), request); + } + } + + #[test] + fn ticket_query_accepts_only_case_states_and_current_attention_filters() { + let parsed: TicketQueryRequest = serde_json::from_value(serde_json::json!({ + "states": ["open", "closed"], "attention": ["blocked", "missing_evidence"] + })) + .unwrap(); + assert_eq!(parsed.states[0], BrowserTicketState::Open); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "in_progress", + "done", + ] { + assert!( + serde_json::from_value::( + serde_json::json!({"states": [state]}) + ) + .is_err(), + "{state}" + ); + } + for attention in ["done_not_closed", "ready"] { + assert!( + serde_json::from_value::( + serde_json::json!({"attention": [attention]}) + ) + .is_err(), + "{attention}" + ); + } + } + + #[test] + fn ticket_case_route_replaces_all_legacy_case_judgment_bypasses() { + let operations = ServerApiMetadata::OPERATIONS; + for removed in [ + "ticket_complete", + "ticket_close", + "ticket_close_record", + "ticket_state_update", + "ticket_state_transition", + "ticket_state_change_add", + "ticket_state_field_set", + "ticket_workflow_state_set", + ] { + assert!( + !operations + .iter() + .any(|operation| operation.operation_id == removed), + "{removed}" + ); + } + let operation = operations + .iter() + .find(|operation| operation.operation_id == "ticket_case_update") + .unwrap(); + assert_eq!(operation.method, HttpMethod::Post); + assert_eq!( + operation.path, + "/workspaces/{workspace_id}/tickets/{ticket_id}/case" + ); } #[cfg(feature = "typescript")] diff --git a/crates/server-api/src/repository_openapi_typescript.rs b/crates/server-api/src/repository_openapi_typescript.rs index cd0dcf3cb..8bdc73a3c 100644 --- a/crates/server-api/src/repository_openapi_typescript.rs +++ b/crates/server-api/src/repository_openapi_typescript.rs @@ -466,11 +466,11 @@ fn validate_ticket_browser_operations( successes: &[("200", "TicketDetail")], }, OperationContract { - path: "/api/w/{workspace_id}/tickets/{id}/state", + path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case", method: "post", - operation_id: "ticket_state_transition", - request: Some("BrowserTransitionTicketStateRequest"), - successes: &[("200", "TicketDetail")], + operation_id: "ticket_case_update", + request: Some("TicketCaseUpdateRequest"), + successes: &[("200", "TicketSummary")], }, OperationContract { path: "/api/w/{workspace_id}/tickets/{id}/ready", @@ -493,13 +493,6 @@ fn validate_ticket_browser_operations( request: Some("BrowserQueueTicketRequest"), successes: &[("200", "TicketQueueOutcome")], }, - OperationContract { - path: "/api/w/{workspace_id}/tickets/{id}/close", - method: "post", - operation_id: "ticket_close", - request: Some("BrowserCloseTicketRequest"), - successes: &[("200", "TicketDetail")], - }, OperationContract { path: "/api/w/{workspace_id}/objectives", method: "get", @@ -1891,6 +1884,54 @@ mod tests { assert!(!detail.contains(" target:")); } + #[test] + fn ticket_browser_generator_exposes_case_judgment_and_closure_projections() { + let openapi = crate::canonical_openapi_document() + .unwrap() + .to_json() + .unwrap(); + let output = generate_ticket_browser_typescript(&openapi).unwrap(); + assert!(output.contains("export type BrowserTicketState = \"open\" | \"closed\";")); + let request = output + .lines() + .find(|line| line.starts_with("export type TicketCaseUpdateRequest =")) + .unwrap(); + for field in [ + "expected_content_digest: string", + "expected_state: TicketState", + "expected_closure_operation_key: string | null", + "operation_key: string", + "action: TicketCaseAction", + "explanation: string", + ] { + assert!(request.contains(field), "missing {field}: {request}"); + } + assert!(!request.contains("author")); + assert!(output.contains("export type TicketClosureReason = \"completed\" | \"not_planned\" | \"duplicate\" | \"unknown\" | { other: string; };")); + let detail = output + .lines() + .find(|line| line.starts_with("export type TicketDetail =")) + .unwrap(); + for field in [ + "state: BrowserTicketState", + "closure_operation_key?: string | null", + "current_closure?: TicketClosureJudgment | null", + "closure_history: Array", + "legacy_handoff?: TicketLegacyWorkflowSnapshot | null", + ] { + assert!(detail.contains(field), "missing {field}: {detail}"); + } + for removed in [ + "BrowserTransitionTicketStateRequest", + "BrowserCloseTicketRequest", + "CompleteTicketRequest", + "BrowserTicketWorkflowState", + "done_not_closed", + ] { + assert!(!output.contains(removed), "{removed}"); + } + } + #[test] fn checked_in_ticket_browser_typescript_is_current() { assert_eq!( diff --git a/crates/ticket/src/case_schema.sql b/crates/ticket/src/case_schema.sql new file mode 100644 index 000000000..5c8ce639a --- /dev/null +++ b/crates/ticket/src/case_schema.sql @@ -0,0 +1,32 @@ +-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority. +CREATE TABLE typed_ticket_case_metadata ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_case_history ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, operation_key), + UNIQUE (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_legacy_workflow_snapshot ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; diff --git a/crates/ticket/src/latest_schema.sql b/crates/ticket/src/latest_schema.sql index a06926101..284272a44 100644 --- a/crates/ticket/src/latest_schema.sql +++ b/crates/ticket/src/latest_schema.sql @@ -131,3 +131,36 @@ CREATE INDEX typed_tickets_workspace_title ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id); CREATE INDEX typed_tickets_workspace_updated ON typed_tickets(workspace_id, updated_at DESC, ticket_id); + +-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority. +CREATE TABLE typed_ticket_case_metadata ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_case_history ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, + operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, operation_key), + UNIQUE (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_legacy_workflow_snapshot ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history +BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot +BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END; +CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; +CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets +WHEN NEW.workflow_state NOT IN ('open', 'closed') +BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END; diff --git a/crates/ticket/src/lib.rs b/crates/ticket/src/lib.rs index ee50132b9..c85112eb1 100644 --- a/crates/ticket/src/lib.rs +++ b/crates/ticket/src/lib.rs @@ -92,6 +92,11 @@ pub enum TicketError { StaleContent { expected: String, actual: String }, #[error("stale ticket workflow state: expected `{expected}`, found `{actual}`")] StaleWorkflowState { expected: String, actual: String }, + #[error("stale Ticket closure operation key: expected {expected:?}, found {actual:?}")] + StaleClosureOperationKey { + expected: Option, + actual: Option, + }, #[error("invalid ticket workflow transition `{from}` -> `{to}`")] InvalidWorkflowTransition { from: String, to: String }, #[error("ticket has unresolved blocking relations: {0}")] @@ -113,6 +118,18 @@ fn io_err(path: impl Into, source: io::Error) -> TicketError { } } +fn parse_sql_case_state(raw: &str, index: usize) -> rusqlite::Result { + TicketState::parse(raw).ok_or_else(|| { + rusqlite::Error::FromSqlConversionFailure( + index, + rusqlite::types::Type::Text, + Box::new(TicketError::Sqlite(format!( + "invalid Ticket case state {raw:?}" + ))), + ) + }) +} + fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result { let workflow_state = row.get::<_, String>(7)?; Ok(TicketSummary { @@ -125,8 +142,7 @@ fn read_ticket_summary_row(row: &rusqlite::Row<'_>) -> rusqlite::Result(8)? != 0, queued_by: row.get(9)?, queued_at: row.get(10)?, @@ -239,61 +255,34 @@ impl From for ExtensibleTicketStatus { schemars::JsonSchema, )] #[serde(rename_all = "snake_case")] -pub enum TicketWorkflowState { - Planning, - Ready, - Queued, - InProgress, - Done, +pub enum TicketState { + Open, Closed, } -impl TicketWorkflowState { +impl TicketState { pub fn as_str(self) -> &'static str { match self { - Self::Planning => "planning", - Self::Ready => "ready", - Self::Queued => "queued", - Self::InProgress => "inprogress", - Self::Done => "done", + Self::Open => "open", Self::Closed => "closed", } } - pub fn parse(value: &str) -> Option { match value { - "planning" => Some(Self::Planning), - "ready" => Some(Self::Ready), - "queued" => Some(Self::Queued), - "inprogress" => Some(Self::InProgress), - "done" => Some(Self::Done), + "open" => Some(Self::Open), "closed" => Some(Self::Closed), _ => None, } } - pub fn default_for_status(status: &ExtensibleTicketStatus) -> Self { match status { ExtensibleTicketStatus::Closed => Self::Closed, - _ => Self::Planning, + _ => Self::Open, } } - - pub fn is_planning_ready_transition(from: Self, to: Self) -> bool { - from == Self::Planning && to == Self::Ready - } - - pub fn is_queue_transition(_from: Self, to: Self) -> bool { - to == Self::Queued - } - - /// Compatibility helper for internal callers; state is not a role phase path. - pub fn is_role_transition(from: Self, to: Self) -> bool { - from != to - } } -impl fmt::Display for TicketWorkflowState { +impl fmt::Display for TicketState { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.write_str(self.as_str()) } @@ -469,53 +458,109 @@ impl TicketStateChange { } } -/// Audited completion independent of repositories, Git, or Merge Requests. +/// Reason attached to a closure judgment, not a classification tag or success inference. +/// `Other` must be nonblank, at most 1024 UTF-8 bytes, and contain no NUL. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TicketCompletion { - pub expected_content_digest: String, - pub expected_state: TicketWorkflowState, - pub operation_key: String, - pub reason: String, - #[serde(default)] - pub references: Vec, - #[serde(default)] - pub author: Option, +#[serde(rename_all = "snake_case")] +pub enum TicketClosureReason { + Completed, + NotPlanned, + Duplicate, + Unknown, + Other(String), } -/// External state mutation with content/state CAS and an immutable operation receipt. -/// Exact replay returns the current authoritative Ticket without a new event or -/// reapplying the recorded state, even after later edits or reopening. -/// Setting `state` to Closed also stores `reason` as the resolution; reopening -/// changes Ticket metadata only and never revives released work identities. -#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] -#[serde(deny_unknown_fields)] -pub struct TicketStateUpdate { - pub expected_content_digest: String, - pub expected_state: TicketWorkflowState, - pub state: TicketWorkflowState, - pub operation_key: String, - pub reason: String, - #[serde(default)] - pub references: Vec, - #[serde(default)] - pub author: Option, +#[derive(Debug, Clone, PartialEq, Eq, Serialize, schemars::JsonSchema)] +#[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] +pub enum TicketCaseAction { + Close { reason: TicketClosureReason }, + Reopen, + CorrectClosure { reason: TicketClosureReason }, } -impl From for TicketStateUpdate { - fn from(request: TicketCompletion) -> Self { - Self { - expected_content_digest: request.expected_content_digest, - expected_state: request.expected_state, - state: TicketWorkflowState::Done, - operation_key: request.operation_key, - reason: request.reason, - references: request.references, - author: request.author, +// Serde's internally tagged unit variants ignore surplus keys despite +// deny_unknown_fields. Decode Reopen as an empty struct to reject hidden reasons. +impl<'de> Deserialize<'de> for TicketCaseAction { + fn deserialize>( + deserializer: D, + ) -> std::result::Result { + #[derive(Deserialize)] + #[serde(tag = "action", rename_all = "snake_case", deny_unknown_fields)] + enum Input { + Close { reason: TicketClosureReason }, + Reopen {}, + CorrectClosure { reason: TicketClosureReason }, } + Ok(match Input::deserialize(deserializer)? { + Input::Close { reason } => Self::Close { reason }, + Input::Reopen {} => Self::Reopen, + Input::CorrectClosure { reason } => Self::CorrectClosure { reason }, + }) } } +fn deserialize_case_closure_observation<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> std::result::Result, D::Error> { + Option::::deserialize(deserializer) +} + +/// Common content/state/closure-key CAS. Exact replay returns current authority, +/// without reapplying old state. The last closure key survives reopening for ABA protection. +/// No repository, MR, worker lifecycle or approval prerequisite is imposed here. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TicketCaseUpdate { + pub expected_content_digest: String, + pub expected_state: TicketState, + /// Required wire field: send explicit null when no closure has been observed. + #[serde(deserialize_with = "deserialize_case_closure_observation")] + #[schemars(required)] + pub expected_closure_operation_key: Option, + pub operation_key: String, + pub action: TicketCaseAction, + pub explanation: String, + #[serde(default)] + pub references: Vec, + #[serde(default)] + pub author: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketClosureJudgment { + pub operation_key: String, + pub reason: TicketClosureReason, + pub explanation: String, + pub references: Vec, + pub author: String, + pub at: String, +} + +/// Immutable case decision, including reopening and corrections. A correction +/// adds a new judgment; it never overwrites the old reason, actor or references. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketCaseHistoryEntry { + pub operation_key: String, + pub previous_closure_operation_key: Option, + pub action: TicketCaseAction, + pub explanation: String, + pub references: Vec, + pub author: String, + pub at: String, +} + +/// Original migration snapshot for downstream routing/tracking handoff only. +/// This is not a second current state authority. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] +pub struct TicketLegacyWorkflowSnapshot { + pub workflow_state: String, + pub workflow_state_explicit: bool, + pub status: String, + pub queued_by: Option, + pub queued_at: Option, + pub resolution: Option, +} + /// SHA-256 of the actual editable Ticket requirements: identity, title, body, /// and ordered repository targets. Comments, timestamps, workflow state, and /// event order are not requirements. Restoring identical content restores its @@ -610,7 +655,7 @@ pub struct NewTicket { pub assignee: Option, pub readiness: Option, pub risk_flags: Vec, - pub workflow_state: Option, + pub workflow_state: Option, pub queued_by: Option, pub queued_at: Option, #[serde(default)] @@ -797,6 +842,7 @@ fn encoded_resolved_targets(targets: &[ResolvedTicketTarget]) -> Result .map_err(|error| TicketError::Conflict(format!("failed to encode Ticket targets: {error}"))) } +/// Frozen schema6 routing receipt fingerprint, retained only for exact historical replay. fn mark_ready_fingerprint_v1( ticket: &Ticket, request: &TicketMarkReady, @@ -836,6 +882,8 @@ fn mark_ready_fingerprint_v1( .collect() } +/// Frozen schema7 routing receipt fingerprint. Legacy phase spellings below are +/// fingerprint material only; mark_ready changes readiness, not case state. fn mark_ready_fingerprint( ticket: &Ticket, request: &TicketMarkReady, @@ -915,28 +963,8 @@ fn validate_mark_ready_replay(ticket: &Ticket, request: &TicketMarkReady) -> Res operation_key: request.operation_key.clone(), }); } - if ticket.meta.workflow_state != TicketWorkflowState::Ready - || ticket.meta.targets != canonical_ticket_targets(&targets) - { - return Err(TicketError::StaleWorkflowState { - expected: TicketWorkflowState::Ready.as_str().to_owned(), - actual: ticket.meta.workflow_state.as_str().to_owned(), - }); - } - Ok(true) -} -fn validate_generic_state_change( - current: TicketWorkflowState, - to: TicketWorkflowState, -) -> Result<()> { - if current == to { - return Err(TicketError::InvalidWorkflowTransition { - from: current.as_str().to_owned(), - to: to.as_str().to_owned(), - }); - } - Ok(()) + Ok(true) } #[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -1059,56 +1087,35 @@ pub struct TicketQueueOutcome { #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TicketListState { - Planning, - Ready, - Queued, - InProgress, - Done, + Open, Closed, } impl TicketListState { pub fn parse(value: &str) -> Option { match value { - "planning" => Some(Self::Planning), - "ready" => Some(Self::Ready), - "queued" => Some(Self::Queued), - "inprogress" => Some(Self::InProgress), - "done" => Some(Self::Done), + "open" => Some(Self::Open), "closed" => Some(Self::Closed), _ => None, } } - pub fn as_str(self) -> &'static str { match self { - Self::Planning => "planning", - Self::Ready => "ready", - Self::Queued => "queued", - Self::InProgress => "inprogress", - Self::Done => "done", + Self::Open => "open", Self::Closed => "closed", } } - - fn matches_workflow_state(self, state: TicketWorkflowState) -> bool { - match self { - Self::Planning => state == TicketWorkflowState::Planning, - Self::Ready => state == TicketWorkflowState::Ready, - Self::Queued => state == TicketWorkflowState::Queued, - Self::InProgress => state == TicketWorkflowState::InProgress, - Self::Done => state == TicketWorkflowState::Done, - Self::Closed => state == TicketWorkflowState::Closed, - } + fn matches_workflow_state(self, state: TicketState) -> bool { + self.as_str() == state.as_str() } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum TicketStateSelector { - /// All non-closed workflow states: planning, ready, queued, inprogress, and done. + /// Open cases, not necessarily ready to start work. Active, - /// Every workflow state, including closed. + /// Every case, including closed. All, /// An explicit set of list-query state tokens. States(BTreeSet), @@ -1154,9 +1161,9 @@ impl TicketListQuery { } } - pub fn matches_state(&self, state: TicketWorkflowState) -> bool { + pub fn matches_state(&self, state: TicketState) -> bool { match &self.state { - TicketStateSelector::Active => state != TicketWorkflowState::Closed, + TicketStateSelector::Active => state != TicketState::Closed, TicketStateSelector::All => true, TicketStateSelector::States(states) => states .iter() @@ -1273,7 +1280,7 @@ pub struct TicketRelationBlocker { pub reason_kind: String, pub relation_kind: TicketRelationKind, pub note: Option, - pub blocking_state: TicketWorkflowState, + pub blocking_state: TicketState, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -1320,7 +1327,8 @@ pub enum TicketWorkspaceRowKind { #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TicketWorkspaceStateOverlay { pub source: String, - pub workflow_state: TicketWorkflowState, + /// Case state only; field name retained temporarily, never execution progress. + pub workflow_state: TicketState, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -1350,7 +1358,7 @@ pub fn project_ticket_workspace_item( ) -> TicketWorkspaceProjection { let visible_overlay = orchestration_overlay .filter(|overlay| { - ticket_overlay_state_has_progressed(summary.workflow_state, overlay.workflow_state) + ticket_overlay_case_differs(summary.workflow_state, overlay.workflow_state) }) .cloned(); let mut projection = derive_ticket_workspace_projection(summary, relation_blockers); @@ -1369,13 +1377,13 @@ pub fn project_ticket_workspace_item( } pub fn ticket_queue_guard( - _summary: &TicketSummary, + summary: &TicketSummary, relation_blockers: &[TicketRelationBlocker], _orchestration_overlay: Option<&TicketWorkspaceStateOverlay>, ) -> TicketQueueGuard { TicketQueueGuard { - can_queue_for_orchestrator: true, - reason: Some("Queue submits only the explicitly requested Ticket; state and dependencies are diagnostic context".to_owned()), + can_queue_for_orchestrator: summary.workflow_state == TicketState::Open, + reason: Some(if summary.workflow_state == TicketState::Open { "Queue submits only the explicitly requested open case; dependencies are diagnostic context" } else { "Closed case must be explicitly reopened before queueing" }.to_owned()), blocked_reason: (!relation_blockers.is_empty()).then(|| format_relation_blockers(relation_blockers)), } } @@ -1386,78 +1394,39 @@ fn derive_ticket_workspace_projection( ) -> TicketWorkspaceProjection { TicketWorkspaceProjection { kind: workspace_row_kind_for_state(summary.workflow_state), - priority: if matches!( - summary.workflow_state, - TicketWorkflowState::Queued | TicketWorkflowState::InProgress - ) { - TicketWorkspaceActionPriority::ActiveWork - } else { - TicketWorkspaceActionPriority::ReadyForQueue - }, - next_action: Some(TicketWorkspaceNextAction::QueueForOrchestrator), + // Cases do not encode execution readiness or routing demand. + priority: TicketWorkspaceActionPriority::Background, + next_action: None, visible_state: summary.workflow_state.to_string(), visible_overlay: None, - disabled_reason: None, - key_hint: Some(format!("Queue only {} for Orchestrator", summary.id)), + disabled_reason: (summary.workflow_state == TicketState::Closed) + .then(|| "Closed case; reopen explicitly before queueing".to_owned()), + key_hint: None, blocked_reason: (!relation_blockers.is_empty()) .then(|| format_relation_blockers(relation_blockers)), queue_guard: ticket_queue_guard(summary, relation_blockers, None), } } -fn workspace_row_kind_for_state(state: TicketWorkflowState) -> TicketWorkspaceRowKind { +fn workspace_row_kind_for_state(state: TicketState) -> TicketWorkspaceRowKind { match state { - TicketWorkflowState::Planning => TicketWorkspaceRowKind::Planning, - TicketWorkflowState::Queued | TicketWorkflowState::InProgress => { - TicketWorkspaceRowKind::ActiveWork - } - TicketWorkflowState::Done | TicketWorkflowState::Closed => TicketWorkspaceRowKind::Review, - TicketWorkflowState::Ready => TicketWorkspaceRowKind::Ticket, + TicketState::Open => TicketWorkspaceRowKind::Ticket, + TicketState::Closed => TicketWorkspaceRowKind::Review, } } fn apply_workspace_overlay_to_projection( projection: &mut TicketWorkspaceProjection, - local: TicketWorkflowState, + local: TicketState, overlay: &TicketWorkspaceStateOverlay, ) { - projection.next_action = Some(TicketWorkspaceNextAction::WaitForOrchestrator); - let overlay_state = overlay.workflow_state.as_str(); - match overlay.workflow_state { - TicketWorkflowState::Done | TicketWorkflowState::Closed => { - projection.kind = TicketWorkspaceRowKind::Review; - projection.priority = TicketWorkspaceActionPriority::Background; - projection.disabled_reason = Some(format!( - "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} until merge/review/close authority updates the current branch.", - overlay.source, - local.as_str() - )); - projection.key_hint = Some(format!( - "Merge pending: local: {} · {}: {overlay_state}", - local.as_str(), - overlay.source - )); - } - TicketWorkflowState::InProgress | TicketWorkflowState::Queued => { - projection.kind = TicketWorkspaceRowKind::ActiveWork; - projection.priority = TicketWorkspaceActionPriority::ActiveWork; - projection.disabled_reason = Some(format!( - "{} worktree overlay shows Ticket state {overlay_state}; local state remains {} and duplicate queue/start actions are suppressed.", - overlay.source, - local.as_str() - )); - projection.key_hint = Some(format!( - "Progress overlay: local: {} · {}: {overlay_state}", - local.as_str(), - overlay.source - )); - } - TicketWorkflowState::Planning | TicketWorkflowState::Ready => {} - } + // Case state overlays cannot imply worker execution or merge progress. + projection.visible_state = + format!("{} ({}: {})", local, overlay.source, overlay.workflow_state); } fn ticket_workspace_state_display( - local: TicketWorkflowState, + local: TicketState, overlay: Option<&TicketWorkspaceStateOverlay>, ) -> String { match overlay { @@ -1470,33 +1439,12 @@ fn ticket_workspace_state_display( } } -fn ticket_overlay_state_has_progressed( - local: TicketWorkflowState, - overlay: TicketWorkflowState, -) -> bool { - workflow_state_progress_rank(overlay) > workflow_state_progress_rank(local) +fn ticket_overlay_case_differs(local: TicketState, overlay: TicketState) -> bool { + local != overlay } -fn workflow_state_progress_rank(state: TicketWorkflowState) -> u8 { - match state { - TicketWorkflowState::Planning => 0, - TicketWorkflowState::Ready => 1, - TicketWorkflowState::Queued => 2, - TicketWorkflowState::InProgress => 3, - TicketWorkflowState::Done => 4, - TicketWorkflowState::Closed => 5, - } -} - -fn compact_ticket_state_label(state: TicketWorkflowState) -> &'static str { - match state { - TicketWorkflowState::Planning => "plan", - TicketWorkflowState::Ready => "ready", - TicketWorkflowState::Queued => "q", - TicketWorkflowState::InProgress => "prog", - TicketWorkflowState::Done => "done", - TicketWorkflowState::Closed => "cls", - } +fn compact_ticket_state_label(state: TicketState) -> &'static str { + state.as_str() } #[derive( @@ -1624,7 +1572,8 @@ pub struct TicketMeta { pub assignee: Option, pub readiness: Option, pub risk_flags: Vec, - pub workflow_state: TicketWorkflowState, + /// Case state only; field name retained temporarily, never execution progress. + pub workflow_state: TicketState, pub workflow_state_explicit: bool, pub queued_by: Option, pub queued_at: Option, @@ -1645,7 +1594,8 @@ pub struct TicketSummary { pub priority: String, pub labels: Vec, pub readiness: Option, - pub workflow_state: TicketWorkflowState, + /// Case state only; field name retained temporarily, never execution progress. + pub workflow_state: TicketState, pub workflow_state_explicit: bool, pub queued_by: Option, pub queued_at: Option, @@ -1676,7 +1626,7 @@ pub struct SqliteTicketListCursor { #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] pub struct SqliteTicketListPageQuery { - pub states: Vec, + pub states: Vec, pub limit: usize, pub after: Option, } @@ -1724,6 +1674,11 @@ pub struct Ticket { pub artifacts: Vec, pub relations: TicketRelationView, pub resolution: Option, + /// Last closure/correction operation key; retained when reopened (ABA guard). + pub closure_operation_key: Option, + pub current_closure: Option, + pub case_history: Vec, + pub legacy_workflow_snapshot: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)] @@ -1797,24 +1752,9 @@ pub trait TicketBackend { fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> Result<()>; fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()>; - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> Result<()>; - fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()>; - fn update_state(&self, _ticket: &str, _request: TicketStateUpdate) -> Result { - Err(TicketError::Conflict( - "backend does not support audited state updates".to_owned(), - )) - } - fn complete(&self, ticket: &str, request: TicketCompletion) -> Result { - self.update_state(ticket, request.into()) - } + fn update_case(&self, ticket: &str, request: TicketCaseUpdate) -> Result; fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> Result; fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> Result; - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()>; fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -1879,22 +1819,10 @@ pub enum TicketBackendOperation { id: TicketIdOrSlug, summary: TicketIntakeSummary, }, - SetStateField { - id: TicketIdOrSlug, - field: String, - change: TicketStateChange, - }, - SetWorkflowState { - id: TicketIdOrSlug, - change: TicketStateChange, - }, - UpdateState { + + UpdateCase { ticket: String, - request: TicketStateUpdate, - }, - Complete { - ticket: String, - request: TicketCompletion, + request: TicketCaseUpdate, }, MarkReady { id: TicketIdOrSlug, @@ -1904,10 +1832,7 @@ pub enum TicketBackendOperation { id: TicketIdOrSlug, queued_by: String, }, - Close { - id: TicketIdOrSlug, - resolution: MarkdownText, - }, + AddTicketRelation { id: TicketIdOrSlug, relation: NewTicketRelation, @@ -1993,19 +1918,9 @@ where backend.add_intake_summary(id, summary)?; TicketBackendOperationResult::Unit } - TicketBackendOperation::SetStateField { id, field, change } => { - backend.set_state_field(id, &field, change)?; - TicketBackendOperationResult::Unit - } - TicketBackendOperation::SetWorkflowState { id, change } => { - backend.set_workflow_state(id, change)?; - TicketBackendOperationResult::Unit - } - TicketBackendOperation::UpdateState { ticket, request } => { - TicketBackendOperationResult::Ticket(backend.update_state(&ticket, request)?) - } - TicketBackendOperation::Complete { ticket, request } => { - TicketBackendOperationResult::Ticket(backend.complete(&ticket, request)?) + + TicketBackendOperation::UpdateCase { ticket, request } => { + TicketBackendOperationResult::Ticket(backend.update_case(&ticket, request)?) } TicketBackendOperation::MarkReady { id, request } => { TicketBackendOperationResult::Ticket(backend.mark_ready(id, request)?) @@ -2013,10 +1928,7 @@ where TicketBackendOperation::QueueReady { id, queued_by } => { TicketBackendOperationResult::QueueOutcome(backend.queue_ready(id, &queued_by)?) } - TicketBackendOperation::Close { id, resolution } => { - backend.close(id, resolution)?; - TicketBackendOperationResult::Unit - } + TicketBackendOperation::AddTicketRelation { id, relation } => { TicketBackendOperationResult::Relation(backend.add_ticket_relation(id, relation)?) } @@ -2223,20 +2135,14 @@ impl SqliteTicketBackend { )) AND (?3 IS NULL OR CASE ticket.workflow_state - WHEN 'ready' THEN 0 WHEN 'planning' THEN 1 - WHEN 'inprogress' THEN 2 WHEN 'queued' THEN 3 - WHEN 'done' THEN 4 WHEN 'closed' THEN 5 ELSE 6 END > ?4 + WHEN 'open' THEN 0 WHEN 'closed' THEN 1 ELSE 2 END > ?4 OR (CASE ticket.workflow_state - WHEN 'ready' THEN 0 WHEN 'planning' THEN 1 - WHEN 'inprogress' THEN 2 WHEN 'queued' THEN 3 - WHEN 'done' THEN 4 WHEN 'closed' THEN 5 ELSE 6 END = ?4 + WHEN 'open' THEN 0 WHEN 'closed' THEN 1 ELSE 2 END = ?4 AND (COALESCE(ticket.updated_at, '') < COALESCE(?5, '') OR (COALESCE(ticket.updated_at, '') = COALESCE(?5, '') AND ticket.ticket_id > ?3)))) ORDER BY CASE ticket.workflow_state - WHEN 'ready' THEN 0 WHEN 'planning' THEN 1 - WHEN 'inprogress' THEN 2 WHEN 'queued' THEN 3 - WHEN 'done' THEN 4 WHEN 'closed' THEN 5 ELSE 6 END ASC, + WHEN 'open' THEN 0 WHEN 'closed' THEN 1 ELSE 2 END ASC, ticket.updated_at DESC, ticket.ticket_id ASC LIMIT ?6", ) @@ -2265,14 +2171,7 @@ impl SqliteTicketBackend { let next = has_more.then(|| { let summary = summaries.last().expect("non-empty page with continuation"); SqliteTicketListCursor { - state_rank: match summary.workflow_state { - TicketWorkflowState::Ready => 0, - TicketWorkflowState::Planning => 1, - TicketWorkflowState::InProgress => 2, - TicketWorkflowState::Queued => 3, - TicketWorkflowState::Done => 4, - TicketWorkflowState::Closed => 5, - }, + state_rank: match summary.workflow_state { TicketState::Open => 0, TicketState::Closed => 1 }, updated_at: summary.updated_at.clone(), ticket_id: summary.id.clone(), } @@ -2352,8 +2251,9 @@ impl SqliteTicketBackend { priority, labels: Vec::new(), readiness, - workflow_state: TicketWorkflowState::parse(&workflow_state) - .unwrap_or(TicketWorkflowState::Planning), + workflow_state: TicketState::parse(&workflow_state).ok_or_else(|| { + TicketError::Sqlite(format!("invalid Ticket case state {workflow_state:?}")) + })?, workflow_state_explicit, queued_by, queued_at, @@ -2416,6 +2316,16 @@ impl SqliteTicketBackend { { return Ok(()); } + if self + .event_attributes + .get("source_actor_role") + .map(String::as_str) + != Some("ticket_worker") + { + return Err(TicketError::Conflict( + "source assignment requires authenticated ticket_worker role".into(), + )); + } let required = |key: &str| { self.event_attributes .get(key) @@ -2440,168 +2350,19 @@ impl SqliteTicketBackend { Ok(()) } - fn state_update_fingerprint( - &self, - ticket_id: &str, - request: &TicketStateUpdate, - body: &MarkdownText, - ) -> Result { - let source = [ - "source_runtime_id", - "source_worker_id", - "source_actor_role", - "source_assignment_id", - ] - .map(|key| self.event_attributes.get(key)); - let mut digest = Sha256::new(); - digest.update(b"ticket.state-update.v2\0"); - digest.update( - serde_json::to_vec(&(ticket_id, request, body, source)) - .map_err(|error| TicketError::Conflict(error.to_string()))?, - ); - Ok(digest - .finalize() - .iter() - .map(|byte| format!("{byte:02x}")) - .collect()) - } - - fn next_legacy_state_operation_key( + fn insert_case_history( &self, conn: &Connection, ticket_id: &str, - ) -> Result { - let index: i64 = conn.query_row( - "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events WHERE workspace_id = ?1 AND ticket_id = ?2", - params![self.workspace_id, ticket_id], |row| row.get(0), - ).map_err(sqlite_err)?; - Ok(format!("legacy-state:{ticket_id}:{index}")) - } - - /// All callers supply a snapshot read inside this same write transaction. - /// Legacy adapters retain their Markdown bodies while sharing CAS, source - /// authorization, state/status/resolution writes, audit, and operation receipts. - fn apply_state_update( - &self, - conn: &Connection, - previous: Ticket, - request: TicketStateUpdate, - body: MarkdownText, - ) -> Result { - validate_required_event_value("operation_key", &request.operation_key)?; - validate_required_event_value("expected_content_digest", &request.expected_content_digest)?; - validate_state_change(&TicketStateChange { - from: request.expected_state.to_string(), - to: request.state.to_string(), - author: request.author.clone(), - reason: request.reason.clone(), - body: body.clone(), - references: request.references.clone(), - })?; - let ticket_id = &previous.meta.id; - let fingerprint = self.state_update_fingerprint(ticket_id, &request, &body)?; - if let Some(event) = previous - .events - .iter() - .find(|event| event.attributes.get("operation_key") == Some(&request.operation_key)) - { - if event - .attributes - .get("fingerprint_version") - .map(String::as_str) - != Some("state-update-v2") - || event.attributes.get("request_fingerprint") != Some(&fingerprint) - { - return Err(TicketError::OperationFingerprintMismatch { - operation_key: request.operation_key.clone(), - }); - } - // The immutable event is the receipt; replay returns current authority - // without appending an event or reapplying the receipt's old state. - return Ok(previous); + entry: &TicketCaseHistoryEntry, + fingerprint: &str, + ) -> Result<()> { + let json = serde_json::to_string(entry).map_err(sqlite_err)?; + conn.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) SELECT ?1, ?2, COALESCE(MAX(ordinal), -1) + 1, ?3, ?4, ?5 FROM typed_ticket_case_history WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, entry.operation_key, fingerprint, json]).map_err(sqlite_err)?; + if !matches!(entry.action, TicketCaseAction::Reopen) { + conn.execute("INSERT INTO typed_ticket_case_metadata (workspace_id, ticket_id, closure_operation_key) VALUES (?1, ?2, ?3) ON CONFLICT (workspace_id, ticket_id) DO UPDATE SET closure_operation_key = excluded.closure_operation_key", params![self.workspace_id, ticket_id, entry.operation_key]).map_err(sqlite_err)?; } - // Exact recorded replay is independent of today's live role/assignment. - // Parent transport authenticates identity before restoring receipt context. - self.require_active_source_assignment(conn, ticket_id)?; - // Compare content and workflow state before any state/audit write, using - // the snapshot read in this same write transaction. Acceptance criteria - // may change without a state change, so a late completion must not win - // solely by arrival order. Equal timestamps do not prove equal content. - let actual = ticket_content_digest(&previous); - if actual != request.expected_content_digest { - return Err(TicketError::StaleContent { - expected: request.expected_content_digest.clone(), - actual, - }); - } - if previous.meta.workflow_state != request.expected_state { - return Err(TicketError::StaleWorkflowState { - expected: request.expected_state.to_string(), - actual: previous.meta.workflow_state.to_string(), - }); - } - let at = now_utc(); - let updated = conn.execute( - "UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, status = CASE WHEN ?3 = 'closed' THEN 'closed' ELSE 'open' END, updated_at = ?4, resolution = CASE WHEN ?3 = 'closed' THEN ?5 ELSE resolution END WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = ?6", - params![self.workspace_id, ticket_id, request.state.as_str(), at, body.as_str(), request.expected_state.as_str()], - ).map_err(sqlite_err)?; - if updated != 1 { - return Err(TicketError::Conflict( - "Ticket changed during state mutation".to_owned(), - )); - } - let kind = if request.state == TicketWorkflowState::Closed { - TicketEventKind::Close - } else { - TicketEventKind::StateChanged - }; - self.insert_event( - conn, - ticket_id, - &TicketEvent { - heading: Some(kind.heading()), - kind, - author: Some(request.author.clone().unwrap_or_else(default_author)), - at: Some(at), - status: Some( - if request.state == TicketWorkflowState::Closed { - "closed" - } else { - "open" - } - .to_owned(), - ), - from: Some(request.expected_state.to_string()), - to: Some(request.state.to_string()), - reason: Some(request.reason.clone()), - state_field: Some("state".to_owned()), - body, - references: request.references.clone(), - attributes: BTreeMap::from([ - ("operation_key".to_owned(), request.operation_key.clone()), - ("request_fingerprint".to_owned(), fingerprint), - ( - "fingerprint_version".to_owned(), - "state-update-v2".to_owned(), - ), - ( - "expected_content_digest".to_owned(), - request.expected_content_digest.clone(), - ), - ]), - }, - )?; - let result = self.load_ticket(conn, ticket_id)?; - if !result - .events - .iter() - .any(|event| event.attributes.get("operation_key") == Some(&request.operation_key)) - { - return Err(TicketError::Conflict( - "state update audit lost its operation key".to_owned(), - )); - } - Ok(result) + Ok(()) } fn with_read(&self, op: impl FnOnce(&Connection) -> Result) -> Result { @@ -2904,8 +2665,7 @@ impl SqliteTicketBackend { assignee: row.get(8)?, readiness: row.get(9)?, risk_flags: Vec::new(), - workflow_state: TicketWorkflowState::parse(&state_raw) - .unwrap_or(TicketWorkflowState::Planning), + workflow_state: parse_sql_case_state(&state_raw, 12)?, workflow_state_explicit: row.get::<_, i64>(13)? != 0, queued_by: row.get(14)?, queued_at: row.get(15)?, @@ -2928,7 +2688,48 @@ impl SqliteTicketBackend { let events = self.load_events(conn, ticket_id)?; let artifacts = self.load_artifacts(conn, ticket_id)?; let relations = self.relation_view_for_meta(conn, &meta)?; + let closure_operation_key = conn.query_row( + "SELECT closure_operation_key FROM typed_ticket_case_metadata WHERE workspace_id = ?1 AND ticket_id = ?2", + params![self.workspace_id, ticket_id], |row| row.get::<_, Option>(0), + ).optional().map_err(sqlite_err)?.flatten(); + let mut statement = conn.prepare("SELECT judgment_json FROM typed_ticket_case_history WHERE workspace_id = ?1 AND ticket_id = ?2 ORDER BY ordinal").map_err(sqlite_err)?; + let rows = statement + .query_map(params![self.workspace_id, ticket_id], |row| { + row.get::<_, String>(0) + }) + .map_err(sqlite_err)?; + let mut case_history = Vec::::new(); + for row in rows { + case_history.push(serde_json::from_str(&row.map_err(sqlite_err)?).map_err(sqlite_err)?); + } + let current_closure = if meta.workflow_state == TicketState::Closed { + case_history + .iter() + .rev() + .find_map(|entry| match &entry.action { + TicketCaseAction::Close { reason } + | TicketCaseAction::CorrectClosure { reason } => Some(TicketClosureJudgment { + operation_key: entry.operation_key.clone(), + reason: reason.clone(), + explanation: entry.explanation.clone(), + references: entry.references.clone(), + author: entry.author.clone(), + at: entry.at.clone(), + }), + TicketCaseAction::Reopen => None, + }) + } else { + None + }; + let legacy_json = conn.query_row("SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id], |row| row.get::<_, String>(0)).optional().map_err(sqlite_err)?; + let legacy_workflow_snapshot = legacy_json + .map(|json| serde_json::from_str(&json).map_err(sqlite_err)) + .transpose()?; Ok(Ticket { + closure_operation_key, + current_closure, + case_history, + legacy_workflow_snapshot, meta, document: TicketDocument { body: MarkdownText::new(body), @@ -3103,7 +2904,7 @@ impl SqliteTicketBackend { Ok(summaries) } - fn state_index(&self, conn: &Connection) -> Result> { + fn state_index(&self, conn: &Connection) -> Result> { let mut stmt = conn .prepare("SELECT ticket_id, workflow_state FROM typed_tickets WHERE workspace_id = ?1") .map_err(sqlite_err)?; @@ -3115,10 +2916,7 @@ impl SqliteTicketBackend { let mut states = HashMap::new(); for row in rows { let (id, state) = row.map_err(sqlite_err)?; - states.insert( - id, - TicketWorkflowState::parse(&state).unwrap_or(TicketWorkflowState::Planning), - ); + states.insert(id, TicketState::parse(&state).unwrap_or(TicketState::Open)); } Ok(states) } @@ -3175,9 +2973,9 @@ fn finish_sqlite_transaction(conn: &Connection, result: Result) -> Result< impl TicketBackend for SqliteTicketBackend { fn default_intake_ready_state_change_body(&self, from: &str) -> String { if is_japanese_record_language(self.record_language()) { - format!("Ticket planning が完了しました。state {from} -> ready。\n") + format!("Ticket のrouting readinessをreadyにしました。case stateは{from}のままです。\n") } else { - format!("Ticket planning complete; state {from} -> ready.\n") + format!("Ticket routing readiness set to ready; case remains {from}.\n") } } @@ -3215,10 +3013,8 @@ impl TicketBackend for SqliteTicketBackend { TicketError::Conflict(format!("failed to allocate unique ticket id: {err}")) })?; let now = now_utc(); - let state = input - .workflow_state - .unwrap_or(TicketWorkflowState::Planning); - let status = if state == TicketWorkflowState::Closed { + let state = input.workflow_state.unwrap_or(TicketState::Open); + let status = if state == TicketState::Closed { ExtensibleTicketStatus::Closed } else { ExtensibleTicketStatus::Open @@ -3253,6 +3049,10 @@ impl TicketBackend for SqliteTicketBackend { .author .unwrap_or_else(|| "SqliteTicketBackend".to_string()); let ticket = Ticket { + closure_operation_key: None, + current_closure: None, + case_history: Vec::new(), + legacy_workflow_snapshot: None, meta, document: TicketDocument { body, @@ -3260,7 +3060,7 @@ impl TicketBackend for SqliteTicketBackend { }, events: vec![TicketEvent { kind: TicketEventKind::Create, - author: Some(author), + author: Some(author.clone()), at: Some(now.clone()), status: None, from: None, @@ -3278,11 +3078,30 @@ impl TicketBackend for SqliteTicketBackend { }; let resource_key = Self::allocate_resource_key(conn, &self.workspace_id, &id, &now)?; self.insert_ticket(conn, &ticket)?; + if state == TicketState::Closed { + let entry = TicketCaseHistoryEntry { + operation_key: format!("create-closed:{id}"), + previous_closure_operation_key: None, + action: TicketCaseAction::Close { + reason: TicketClosureReason::Unknown, + }, + explanation: "Created as a closed case; no typed closure reason was supplied." + .into(), + references: Vec::new(), + author, + at: now.clone(), + }; + self.insert_case_history(conn, &id, &entry, "initial-closed-v9")?; + } let reference = TicketRef { id: id.clone(), resource_key: Some(resource_key), slug: id.clone(), - status: TicketStatus::Open, + status: if state == TicketState::Closed { + TicketStatus::Closed + } else { + TicketStatus::Open + }, }; let persisted = self.load_ticket(conn, &id)?; Ok((reference, persisted)) @@ -3442,8 +3261,10 @@ impl TicketBackend for SqliteTicketBackend { }) } - fn add_state_changed(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { - self.set_workflow_state(id, change) + fn add_state_changed(&self, _id: TicketIdOrSlug, _change: TicketStateChange) -> Result<()> { + Err(TicketError::Conflict( + "case changes require update_case with content/state/closure-key CAS".into(), + )) } fn add_intake_summary(&self, id: TicketIdOrSlug, summary: TicketIntakeSummary) -> Result<()> { @@ -3473,57 +3294,72 @@ impl TicketBackend for SqliteTicketBackend { }) } - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> Result<()> { - if field != "state" { - return Err(TicketError::Conflict(format!( - "unsupported Ticket state field: {field}" - ))); + fn update_case(&self, ticket: &str, request: TicketCaseUpdate) -> Result { + validate_case_text("operation_key", &request.operation_key)?; + validate_case_text("expected_content_digest", &request.expected_content_digest)?; + validate_case_text("explanation", &request.explanation)?; + if let Some(key) = &request.expected_closure_operation_key { + validate_case_text("expected_closure_operation_key", key)?; } - self.set_workflow_state(id, change) - } - - fn set_workflow_state(&self, id: TicketIdOrSlug, change: TicketStateChange) -> Result<()> { - let from = TicketWorkflowState::parse(&change.from).ok_or_else(|| { - TicketError::InvalidWorkflowTransition { - from: change.from.clone(), - to: change.to.clone(), + if let Some(author) = &request.author { + validate_case_text("author", author)?; + } + for reference in &request.references { + validate_case_text("reference_kind", &reference.kind)?; + validate_case_text("reference_target", &reference.target)?; + } + if let TicketCaseAction::Close { + reason: TicketClosureReason::Other(reason), + } + | TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Other(reason), + } = &request.action + { + validate_case_text("custom closure reason", reason)?; + if reason.len() > 1024 { + return Err(TicketError::Conflict( + "custom closure reason exceeds 1024 UTF-8 bytes".into(), + )); } - })?; - let to = TicketWorkflowState::parse(&change.to).ok_or_else(|| { - TicketError::InvalidWorkflowTransition { - from: change.from.clone(), - to: change.to.clone(), - } - })?; - validate_generic_state_change(from, to)?; - self.with_write(|conn| { - let ticket_id = self.resolve_ticket_id(conn, id)?; - let previous = self.load_ticket(conn, &ticket_id)?; - let request = TicketStateUpdate { - expected_content_digest: ticket_content_digest(&previous), - expected_state: from, - state: to, - operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, - reason: change.reason, - references: change.references, - author: Some(change.author.unwrap_or_else(default_author)), - }; - self.apply_state_update(conn, previous, request, change.body) - .map(|_| ()) - }) - } - - fn update_state(&self, ticket: &str, request: TicketStateUpdate) -> Result { + } self.with_write(|conn| { let ticket_id = self.resolve_ticket_id(conn, ticket.into())?; let previous = self.load_ticket(conn, &ticket_id)?; - let body = MarkdownText::new(&request.reason); - self.apply_state_update(conn, previous, request, body) + let source = ["source_runtime_id", "source_worker_id", "source_actor_role", "source_assignment_id"].map(|key| self.event_attributes.get(key)); + let fingerprint_input = serde_json::to_vec(&("ticket.case-update.v1", &ticket_id, &request, source)).map_err(sqlite_err)?; + let fingerprint = Sha256::digest(fingerprint_input).iter().map(|byte| format!("{byte:02x}")).collect::(); + let receipt = conn.query_row("SELECT request_fingerprint FROM typed_ticket_case_history WHERE workspace_id = ?1 AND ticket_id = ?2 AND operation_key = ?3", params![self.workspace_id, ticket_id, request.operation_key], |row| row.get::<_, String>(0)).optional().map_err(sqlite_err)?; + if let Some(receipt) = receipt { + return if receipt == fingerprint { Ok(previous) } else { Err(TicketError::OperationFingerprintMismatch { operation_key: request.operation_key.clone() }) }; + } + // Do not reuse a key already owned by another audited operation. + if previous.events.iter().any(|event| event.attributes.get("operation_key") == Some(&request.operation_key)) { + return Err(TicketError::OperationFingerprintMismatch { operation_key: request.operation_key.clone() }); + } + self.require_active_source_assignment(conn, &ticket_id)?; + let actual = ticket_content_digest(&previous); + if actual != request.expected_content_digest { return Err(TicketError::StaleContent { expected: request.expected_content_digest.clone(), actual }); } + if previous.meta.workflow_state != request.expected_state { return Err(TicketError::StaleWorkflowState { expected: request.expected_state.to_string(), actual: previous.meta.workflow_state.to_string() }); } + if previous.closure_operation_key != request.expected_closure_operation_key { return Err(TicketError::StaleClosureOperationKey { expected: request.expected_closure_operation_key.clone(), actual: previous.closure_operation_key }); } + let state = match &request.action { + TicketCaseAction::Close { .. } if previous.meta.workflow_state == TicketState::Open => TicketState::Closed, + TicketCaseAction::Reopen if previous.meta.workflow_state == TicketState::Closed => TicketState::Open, + TicketCaseAction::CorrectClosure { .. } if previous.meta.workflow_state == TicketState::Closed && previous.current_closure.is_some() => TicketState::Closed, + _ => return Err(TicketError::Conflict("Close requires open; Reopen and CorrectClosure require closed with a current judgment".into())), + }; + let at = now_utc(); + let entry = TicketCaseHistoryEntry { operation_key: request.operation_key.clone(), previous_closure_operation_key: request.expected_closure_operation_key.clone(), action: request.action.clone(), explanation: request.explanation.clone(), references: request.references.clone(), author: request.author.clone().unwrap_or_else(default_author), at: at.clone() }; + self.insert_case_history(conn, &ticket_id, &entry, &fingerprint)?; + conn.execute("UPDATE typed_tickets SET workflow_state = ?3, workflow_state_explicit = 1, status = ?3, updated_at = ?4, resolution = CASE WHEN ?3 = 'closed' THEN ?5 ELSE NULL END WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, ticket_id, state.as_str(), at, request.explanation]).map_err(sqlite_err)?; + let kind = if matches!(request.action, TicketCaseAction::Close { .. }) { TicketEventKind::Close } else { TicketEventKind::StateChanged }; + self.insert_event(conn, &ticket_id, &TicketEvent { + heading: Some(kind.heading()), kind, author: Some(entry.author), at: Some(at), status: Some(state.to_string()), from: Some(previous.meta.workflow_state.to_string()), to: Some(state.to_string()), reason: Some(request.explanation.clone()), state_field: Some("case".into()), body: MarkdownText::new(&request.explanation), references: request.references.clone(), + attributes: BTreeMap::from([("operation_key".into(), request.operation_key.clone()), ("fingerprint_version".into(), "case-update-v1".into()), ("request_fingerprint".into(), fingerprint.clone()), ("case_action".into(), serde_json::to_string(&request.action).map_err(sqlite_err)?), ("expected_content_digest".into(), request.expected_content_digest.clone())]), + })?; + let result = self.load_ticket(conn, &ticket_id)?; + let audited = result.events.last().is_some_and(|event| event.attributes.get("operation_key") == Some(&request.operation_key) && event.attributes.get("request_fingerprint") == Some(&fingerprint)); + if !audited { return Err(TicketError::Conflict("Ticket case audit context overwrote its immutable operation receipt".into())); } + Ok(result) }) } @@ -3536,9 +3372,9 @@ impl TicketBackend for SqliteTicketBackend { return Ok(ticket); } self.require_active_source_assignment(conn, &ticket_id)?; - if ticket.meta.workflow_state != TicketWorkflowState::Planning { + if ticket.meta.workflow_state != TicketState::Open { return Err(TicketError::StaleWorkflowState { - expected: TicketWorkflowState::Planning.as_str().to_owned(), + expected: TicketState::Open.as_str().to_owned(), actual: ticket.meta.workflow_state.as_str().to_owned(), }); } @@ -3599,10 +3435,10 @@ impl TicketBackend for SqliteTicketBackend { author: Some(request.author.unwrap_or_else(default_author)), at: Some(at.clone()), status: None, - from: Some(TicketWorkflowState::Planning.as_str().to_owned()), - to: Some(TicketWorkflowState::Ready.as_str().to_owned()), + from: Some(TicketState::Open.as_str().to_owned()), + to: Some(TicketState::Open.as_str().to_owned()), reason: Some(reason), - state_field: Some("state".to_owned()), + state_field: Some("readiness".to_owned()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!( "Implementation targets [{target_summary}] were validated and the Ticket was marked ready." @@ -3617,7 +3453,7 @@ impl TicketBackend for SqliteTicketBackend { }, )?; let updated = conn.execute( - "UPDATE typed_tickets SET workflow_state = 'ready', workflow_state_explicit = 1, updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'planning'", + "UPDATE typed_tickets SET readiness = 'ready', updated_at = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = 'open'", params![self.workspace_id, ticket_id, at], ) .map_err(sqlite_err)?; @@ -3641,6 +3477,7 @@ impl TicketBackend for SqliteTicketBackend { let requested_ticket = self.resolve_ticket_id(conn, id)?; self.require_active_source_assignment(conn, &requested_ticket)?; let previous = self.load_ticket(conn, &requested_ticket)?; + if previous.meta.workflow_state == TicketState::Closed { return Err(TicketError::Conflict("queue requires an open case; reopen explicitly with update_case".into())); } let mut attributes = BTreeMap::from([ ("queue_root_ticket".to_owned(), requested_ticket.clone()), ("queued_by".to_owned(), queued_by.to_owned()), @@ -3656,7 +3493,7 @@ impl TicketBackend for SqliteTicketBackend { let at = now_utc(); attributes.insert("queued_at".to_owned(), at.clone()); conn.execute( - "UPDATE typed_tickets SET workflow_state = 'queued', status = 'open', workflow_state_explicit = 1, queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", + "UPDATE typed_tickets SET queued_by = ?3, queued_at = ?4, updated_at = ?4 WHERE workspace_id = ?1 AND ticket_id = ?2", params![self.workspace_id, requested_ticket, queued_by, at], ).map_err(sqlite_err)?; self.insert_event(conn, &requested_ticket, &TicketEvent { @@ -3665,9 +3502,9 @@ impl TicketBackend for SqliteTicketBackend { at: Some(at), status: Some("open".to_owned()), from: Some(previous.meta.workflow_state.to_string()), - to: Some("queued".to_owned()), + to: Some(previous.meta.workflow_state.to_string()), reason: Some("queued".to_owned()), - state_field: Some("state".to_owned()), + state_field: Some("routing".to_owned()), heading: Some(TicketEventKind::StateChanged.heading()), body: MarkdownText::new(format!("Queued for Orchestrator by {queued_by}.")), references: Vec::new(), @@ -3680,29 +3517,6 @@ impl TicketBackend for SqliteTicketBackend { }) } - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> Result<()> { - if resolution.as_str().trim().is_empty() { - return Err(TicketError::Conflict( - "Ticket close requires a non-empty resolution".to_owned(), - )); - } - self.with_write(|conn| { - let ticket_id = self.resolve_ticket_id(conn, id)?; - let previous = self.load_ticket(conn, &ticket_id)?; - let request = TicketStateUpdate { - expected_content_digest: ticket_content_digest(&previous), - expected_state: previous.meta.workflow_state, - state: TicketWorkflowState::Closed, - operation_key: self.next_legacy_state_operation_key(conn, &ticket_id)?, - reason: "closed".to_owned(), - references: Vec::new(), - author: Some(default_author()), - }; - self.apply_state_update(conn, previous, request, resolution) - .map(|_| ()) - }) - } - fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -3935,16 +3749,13 @@ fn relation_notice_for_incoming(relation: &TicketRelation) -> Option bool { - matches!( - state, - TicketWorkflowState::Done | TicketWorkflowState::Closed - ) +fn ticket_state_resolved(state: TicketState) -> bool { + matches!(state, TicketState::Closed) } fn transitive_dependency_blockers( requested_ticket: &str, - states: &HashMap, + states: &HashMap, relations: &[TicketRelation], ) -> Result> { type DependencyEdge = (String, String, TicketRelationKind, Option); @@ -3984,7 +3795,7 @@ fn transitive_dependency_blockers( fn collect( ticket: &str, requested_ticket: &str, - states: &HashMap, + states: &HashMap, prerequisites: &BTreeMap>, marks: &mut BTreeMap, blockers: &mut BTreeMap, @@ -4066,7 +3877,7 @@ fn transitive_dependency_blockers( fn relation_view_from_records( meta: &TicketMeta, records: &[TicketRelation], - states: &HashMap, + states: &HashMap, ) -> TicketRelationView { let mut view = TicketRelationView::default(); for relation in records { @@ -4076,7 +3887,7 @@ fn relation_view_from_records( let state = states .get(&relation.target) .copied() - .unwrap_or(TicketWorkflowState::Planning); + .unwrap_or(TicketState::Open); if !ticket_state_resolved(state) { view.blockers.push(TicketRelationBlocker { blocking_ticket: relation.target.clone(), @@ -4104,7 +3915,7 @@ fn relation_view_from_records( let state = states .get(&relation.ticket_id) .copied() - .unwrap_or(TicketWorkflowState::Planning); + .unwrap_or(TicketState::Open); if !ticket_state_resolved(state) { view.blockers.push(TicketRelationBlocker { blocking_ticket: relation.ticket_id.clone(), @@ -4336,6 +4147,15 @@ fn validate_event_attr(key: &str, value: &str) -> Result<()> { Ok(()) } +fn validate_case_text(label: &str, value: &str) -> Result<()> { + if value.trim().is_empty() || value.contains('\0') { + return Err(TicketError::Conflict(format!( + "Ticket case {label} must be nonblank and NUL-free" + ))); + } + Ok(()) +} + fn validate_required_event_value(label: &str, value: &str) -> Result<()> { if value.trim().is_empty() { return Err(TicketError::Conflict(format!( @@ -4345,26 +4165,6 @@ fn validate_required_event_value(label: &str, value: &str) -> Result<()> { validate_event_attr(label, value) } -fn validate_state_change(change: &TicketStateChange) -> Result<()> { - validate_required_event_value("from", &change.from)?; - validate_required_event_value("to", &change.to)?; - // Reasons are persisted as SQLite text, not legacy thread-comment attributes. - // Preserve free-form Markdown without per-field length or single-line limits. - if change.reason.trim().is_empty() { - return Err(TicketError::Conflict( - "state_changed event requires non-empty reason".to_owned(), - )); - } - if let Some(author) = change.author.as_deref() { - validate_required_event_value("author", author)?; - } - for reference in &change.references { - validate_required_event_value("reference_kind", &reference.kind)?; - validate_required_event_value("reference_target", &reference.target)?; - } - Ok(()) -} - fn validate_intake_summary(summary: &TicketIntakeSummary) -> Result<()> { let body = summary.body.as_str(); if body.trim().is_empty() { @@ -4662,7 +4462,7 @@ mod tests { )); } - fn summary_with_state(state: TicketWorkflowState) -> TicketSummary { + fn summary_with_state(state: TicketState) -> TicketSummary { TicketSummary { id: "000TEST".to_string(), resource_key: Some("T-1".to_string()), @@ -4681,7 +4481,7 @@ mod tests { } } - fn blocker_with_state(state: TicketWorkflowState) -> TicketRelationBlocker { + fn blocker_with_state(state: TicketState) -> TicketRelationBlocker { TicketRelationBlocker { blocking_ticket: "000BLOCK".to_string(), reason_kind: "depends_on".to_string(), @@ -4705,9 +4505,9 @@ mod tests { #[test] fn transitive_blockers_project_internal_dependency_cycle_as_blocking() { let states = HashMap::from([ - ("root".to_owned(), TicketWorkflowState::Ready), - ("branch".to_owned(), TicketWorkflowState::Ready), - ("cycle".to_owned(), TicketWorkflowState::Ready), + ("root".to_owned(), TicketState::Open), + ("branch".to_owned(), TicketState::Open), + ("cycle".to_owned(), TicketState::Open), ]); let relations = [ dependency_relation("root", "branch"), @@ -4719,7 +4519,7 @@ mod tests { assert!(blockers.iter().any(|blocker| { blocker.blocking_ticket == "root" && blocker.reason_kind == "dependency_cycle" })); - let summary = summary_with_state(TicketWorkflowState::Ready); + let summary = summary_with_state(TicketState::Open); let mut summary = summary; summary.id = "root".to_string(); assert!( @@ -4735,141 +4535,76 @@ mod tests { } #[test] - fn workspace_projection_queues_ready_ticket_for_orchestrator() { - let summary = summary_with_state(TicketWorkflowState::Ready); + fn open_case_projection_does_not_imply_execution_readiness() { + let summary = summary_with_state(TicketState::Open); let projection = project_ticket_workspace_item(&summary, &[], None); assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); assert_eq!( projection.priority, - TicketWorkspaceActionPriority::ReadyForQueue - ); - assert_eq!( - projection.next_action, - Some(TicketWorkspaceNextAction::QueueForOrchestrator) + TicketWorkspaceActionPriority::Background ); + assert_eq!(projection.next_action, None); assert!(projection.queue_guard.can_queue_for_orchestrator); assert!(projection.disabled_reason.is_none()); } #[test] - fn workspace_projection_queues_ready_ticket_with_planning_dependency() { - let summary = summary_with_state(TicketWorkflowState::Ready); - let blockers = [blocker_with_state(TicketWorkflowState::Planning)]; + fn open_case_projection_keeps_dependencies_as_context() { + let summary = summary_with_state(TicketState::Open); + let blockers = [blocker_with_state(TicketState::Open)]; let projection = project_ticket_workspace_item(&summary, &blockers, None); assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); - assert_eq!( - projection.next_action, - Some(TicketWorkspaceNextAction::QueueForOrchestrator) - ); + assert_eq!(projection.next_action, None); assert!(projection.queue_guard.can_queue_for_orchestrator); assert!(projection.blocked_reason.is_some()); assert!(projection.disabled_reason.is_none()); } #[test] - fn workspace_projection_queues_ready_ticket_with_queued_dependency_context() { - let summary = summary_with_state(TicketWorkflowState::Ready); - let blockers = [blocker_with_state(TicketWorkflowState::Queued)]; + fn explicit_queue_guard_remains_separate_from_case_projection() { + let summary = summary_with_state(TicketState::Open); + let blockers = [blocker_with_state(TicketState::Open)]; let projection = project_ticket_workspace_item(&summary, &blockers, None); - assert_eq!( - projection.next_action, - Some(TicketWorkspaceNextAction::QueueForOrchestrator) - ); + assert_eq!(projection.next_action, None); assert!(projection.queue_guard.can_queue_for_orchestrator); assert!(projection.blocked_reason.is_some()); - assert!( - projection - .key_hint - .as_deref() - .unwrap_or_default() - .contains("Queue only") - ); + assert!(projection.key_hint.is_none()); } #[test] - fn workspace_projection_overlay_retains_context_without_queue_gate() { - let summary = summary_with_state(TicketWorkflowState::Ready); + fn workspace_projection_case_overlay_never_implies_execution_progress() { + let summary = summary_with_state(TicketState::Open); let overlay = TicketWorkspaceStateOverlay { - source: "orchestration".to_string(), - workflow_state: TicketWorkflowState::InProgress, + source: "other-branch".into(), + workflow_state: TicketState::Closed, }; let projection = project_ticket_workspace_item(&summary, &[], Some(&overlay)); - - assert_eq!(projection.kind, TicketWorkspaceRowKind::ActiveWork); - assert_eq!( - projection.next_action, - Some(TicketWorkspaceNextAction::WaitForOrchestrator) - ); - assert_eq!(projection.visible_state, "ready→prog"); - assert!(projection.queue_guard.can_queue_for_orchestrator); + assert_eq!(projection.kind, TicketWorkspaceRowKind::Ticket); + assert_eq!(projection.next_action, None); assert!(projection.visible_overlay.is_some()); + assert!(projection.queue_guard.can_queue_for_orchestrator); + let closed = + project_ticket_workspace_item(&summary_with_state(TicketState::Closed), &[], None); + assert!(!closed.queue_guard.can_queue_for_orchestrator); + assert!(closed.next_action.is_none()); } #[test] - fn generic_state_change_allows_direct_start_without_phase_path() { - assert!( - validate_generic_state_change( - TicketWorkflowState::Ready, - TicketWorkflowState::InProgress - ) - .is_ok() - ); - assert!( - validate_generic_state_change( - TicketWorkflowState::Planning, - TicketWorkflowState::InProgress - ) - .is_ok() - ); - } - - #[test] - fn workflow_state_rejects_legacy_intake_alias() { - assert_eq!( - TicketWorkflowState::parse("planning"), - Some(TicketWorkflowState::Planning) - ); - assert_eq!(TicketWorkflowState::parse("intake"), None); - assert_eq!(TicketWorkflowState::Planning.as_str(), "planning"); - assert_eq!( - TicketWorkflowState::default_for_status(&ExtensibleTicketStatus::Open), - TicketWorkflowState::Planning - ); - } - - #[test] - fn workflow_state_transition_graph_allows_planning_lane_and_returns() { - assert!(TicketWorkflowState::is_planning_ready_transition( - TicketWorkflowState::Planning, - TicketWorkflowState::Ready - )); - assert!(TicketWorkflowState::is_queue_transition( - TicketWorkflowState::Ready, - TicketWorkflowState::Queued - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::InProgress, - TicketWorkflowState::Done - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::Ready, - TicketWorkflowState::Planning - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::Queued, - TicketWorkflowState::Planning - )); - assert!(TicketWorkflowState::is_role_transition( - TicketWorkflowState::Planning, - TicketWorkflowState::Queued - )); + fn case_state_parser_rejects_legacy_workflow_values() { + assert_eq!(TicketState::parse("open"), Some(TicketState::Open)); + for legacy in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "intake", + ] { + assert_eq!(TicketState::parse(legacy), None); + } } #[test] @@ -4878,10 +4613,10 @@ mod tests { let backend = backend(&tmp); let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap(); let mut ready_input = NewTicket::new("Ready Ticket"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); + ready_input.workflow_state = Some(TicketState::Open); let ready = backend.create(ready_input).unwrap(); let mut closed_input = NewTicket::new("Closed Ticket"); - closed_input.workflow_state = Some(TicketWorkflowState::Closed); + closed_input.workflow_state = Some(TicketState::Closed); let closed = backend.create(closed_input).unwrap(); let active = backend.list(TicketListQuery::default()).unwrap(); @@ -4903,14 +4638,13 @@ mod tests { assert!(all_ids.contains(&closed.id.as_str())); let ready_only = backend - .list(TicketListQuery::state(TicketListState::Ready)) + .list(TicketListQuery::state(TicketListState::Open)) .unwrap(); - assert_eq!(ready_only.len(), 1); - assert_eq!(ready_only[0].id, ready.id); + assert_eq!(ready_only.len(), 2); let planning_or_closed = backend .list(TicketListQuery::states([ - TicketListState::Planning, + TicketListState::Open, TicketListState::Closed, ])) .unwrap(); @@ -4920,7 +4654,7 @@ mod tests { .collect::>(); assert!(explicit_ids.contains(&planning.id.as_str())); assert!(explicit_ids.contains(&closed.id.as_str())); - assert!(!explicit_ids.contains(&ready.id.as_str())); + assert!(explicit_ids.contains(&ready.id.as_str())); } #[test] @@ -4930,11 +4664,11 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut root_input = NewTicket::new("Ready root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); + root_input.workflow_state = Some(TicketState::Open); root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); let mut middle_input = NewTicket::new("Queued middle"); - middle_input.workflow_state = Some(TicketWorkflowState::Queued); + middle_input.workflow_state = Some(TicketState::Open); middle_input.targets = vec![write_target("main")]; let middle = backend.create(middle_input).unwrap(); let leaf = backend.create(NewTicket::new("Planning leaf")).unwrap(); @@ -4961,13 +4695,12 @@ mod tests { assert!(check.queue_guard.can_queue_for_orchestrator); assert_eq!(check.queue_tickets, vec![check.ticket.id.clone()]); assert!(check.blockers.iter().any(|blocker| { - blocker.blocking_ticket == leaf.id - && blocker.blocking_state == TicketWorkflowState::Planning + blocker.blocking_ticket == leaf.id && blocker.blocking_state == TicketState::Open })); let page = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 10, after: None, }) @@ -4978,8 +4711,7 @@ mod tests { .find(|item| item.summary.id == check.ticket.id) .unwrap(); assert!(item.relation_blockers.iter().any(|blocker| { - blocker.blocking_ticket == leaf.id - && blocker.blocking_state == TicketWorkflowState::Planning + blocker.blocking_ticket == leaf.id && blocker.blocking_state == TicketState::Open })); assert!( project_ticket_workspace_item(&item.summary, &item.relation_blockers, None) @@ -5013,11 +4745,11 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut root_input = NewTicket::new("Ready root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); + root_input.workflow_state = Some(TicketState::Open); root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); let mut done_input = NewTicket::new("Done dependency"); - done_input.workflow_state = Some(TicketWorkflowState::Done); + done_input.workflow_state = Some(TicketState::Closed); done_input.targets = vec![write_target("main")]; let done = backend.create(done_input).unwrap(); for (ticket_id, target) in [ @@ -5047,7 +4779,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); assert_eq!( backend @@ -5055,7 +4787,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Done + TicketState::Closed ); } @@ -5066,15 +4798,15 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut first_input = NewTicket::new("First ready Ticket"); - first_input.workflow_state = Some(TicketWorkflowState::Ready); + first_input.workflow_state = Some(TicketState::Open); first_input.targets = vec![write_target("main")]; let first = backend.create(first_input).unwrap(); let mut second_input = NewTicket::new("Second ready Ticket"); - second_input.workflow_state = Some(TicketWorkflowState::Ready); + second_input.workflow_state = Some(TicketState::Open); second_input.targets = vec![write_target("main")]; let second = backend.create(second_input).unwrap(); let mut third_input = NewTicket::new("Third ready Ticket"); - third_input.workflow_state = Some(TicketWorkflowState::Ready); + third_input.workflow_state = Some(TicketState::Open); third_input.targets = vec![write_target("main")]; let third = backend.create(third_input).unwrap(); for (ticket, target) in [ @@ -5109,7 +4841,7 @@ mod tests { ); let page = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 10, after: None, }) @@ -5130,7 +4862,7 @@ mod tests { assert_eq!(outcome.queued_tickets, vec![first.id.clone()]); assert_eq!( backend.show(first.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); for ticket_id in [second.id, third.id] { assert_eq!( @@ -5139,7 +4871,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); } } @@ -5151,11 +4883,11 @@ mod tests { .unwrap() .with_target_authority(Arc::new(TestTargetAuthority)); let mut dependency_input = NewTicket::new("Invalid ready dependency"); - dependency_input.workflow_state = Some(TicketWorkflowState::Ready); + dependency_input.workflow_state = Some(TicketState::Open); dependency_input.targets = vec![write_target("unknown")]; let dependency = backend.create(dependency_input).unwrap(); let mut root_input = NewTicket::new("Queue root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); + root_input.workflow_state = Some(TicketState::Open); root_input.targets = vec![write_target("main")]; let root = backend.create(root_input).unwrap(); backend @@ -5180,7 +4912,7 @@ mod tests { assert_eq!(outcome.queued_tickets, vec![root.id.clone()]); assert_eq!( backend.show(root.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); for ticket_id in [dependency.id] { assert_eq!( @@ -5189,7 +4921,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); } } @@ -5202,7 +4934,7 @@ mod tests { .with_target_authority(Arc::new(TestTargetAuthority)); let mut dependency = NewTicket::new("Dependency"); dependency.targets = vec![write_target("main")]; - dependency.workflow_state = Some(TicketWorkflowState::Ready); + dependency.workflow_state = Some(TicketState::Open); let dependency = backend.create(dependency).unwrap(); let mut implementation = NewTicket::new("Implementation"); implementation.targets = vec![write_target("main")]; @@ -5263,11 +4995,8 @@ mod tests { let queued_dependency = backend .show(TicketIdOrSlug::Id(dependency.id.clone())) .unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!( - queued_dependency.meta.workflow_state, - TicketWorkflowState::Ready - ); + assert_eq!(queued.meta.workflow_state, TicketState::Open); + assert_eq!(queued_dependency.meta.workflow_state, TicketState::Open); assert_eq!(queued.meta.queued_by.as_deref(), Some("orchestrator")); assert_eq!(queued.relations.blockers.len(), 1); assert_eq!(queued.relations.blockers[0].blocking_ticket, dependency.id); @@ -5394,13 +5123,13 @@ mod tests { let db_path = tmp.path().join("workspace.db"); let backend = SqliteTicketBackend::open(&db_path, "workspace-test").unwrap(); let mut blocker_input = NewTicket::new("Blocker"); - blocker_input.workflow_state = Some(TicketWorkflowState::InProgress); + blocker_input.workflow_state = Some(TicketState::Open); let blocker = backend.create(blocker_input).unwrap(); let mut blocked_input = NewTicket::new("Blocked"); - blocked_input.workflow_state = Some(TicketWorkflowState::Ready); + blocked_input.workflow_state = Some(TicketState::Open); let blocked = backend.create(blocked_input).unwrap(); let mut newest_input = NewTicket::new("Newest"); - newest_input.workflow_state = Some(TicketWorkflowState::Ready); + newest_input.workflow_state = Some(TicketState::Open); let newest = backend.create(newest_input).unwrap(); backend .add_ticket_relation( @@ -5478,7 +5207,7 @@ mod tests { ); assert_eq!( blocked_item.relation_blockers[0].blocking_state, - TicketWorkflowState::InProgress + TicketState::Open ); assert_eq!(blocked_item.relation_blockers[0].reason_kind, "depends_on"); let debug = format!("{projection:?}"); @@ -5493,13 +5222,9 @@ mod tests { let backend = SqliteTicketBackend::open(&db_path, "workspace-test").unwrap(); let mut ids = Vec::new(); for (title, state, updated_at) in [ - ("Newest", TicketWorkflowState::Ready, "2026-08-12T03:00:00Z"), - ("Middle", TicketWorkflowState::Ready, "2026-08-12T02:00:00Z"), - ( - "Oldest", - TicketWorkflowState::Planning, - "2026-08-12T01:00:00Z", - ), + ("Newest", TicketState::Open, "2026-08-12T03:00:00Z"), + ("Middle", TicketState::Open, "2026-08-12T02:00:00Z"), + ("Oldest closed", TicketState::Closed, "2026-08-12T01:00:00Z"), ] { let mut input = NewTicket::new(title); input.workflow_state = Some(state); @@ -5523,20 +5248,20 @@ mod tests { .unwrap(); let combined_lane = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready, TicketWorkflowState::Planning], + states: vec![TicketState::Open, TicketState::Closed], limit: 1, after: None, }) .unwrap(); assert_eq!( combined_lane.items[0].summary.workflow_state, - TicketWorkflowState::Ready, + TicketState::Open, "lane pagination order must match the UI's state-primary order", ); let first = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 1, after: None, }) @@ -5545,7 +5270,7 @@ mod tests { assert!(first.has_more); let mut inserted = NewTicket::new("Inserted"); - inserted.workflow_state = Some(TicketWorkflowState::Ready); + inserted.workflow_state = Some(TicketState::Open); let inserted = backend.create(inserted).unwrap(); Connection::open(&db_path) .unwrap() @@ -5557,7 +5282,7 @@ mod tests { let second = backend .list_workspace_projection_page(SqliteTicketListPageQuery { - states: vec![TicketWorkflowState::Ready], + states: vec![TicketState::Open], limit: 1, after: first.next, }) @@ -5603,10 +5328,22 @@ mod tests { NewTicketEvent::new(TicketEventKind::Comment, "Imported into SQLite."), ) .unwrap(); + let observed = backend.show(created.id.clone().into()).unwrap(); backend - .close( - TicketIdOrSlug::Id(created.id.clone()), - MarkdownText::new("Done."), + .update_case( + &created.id, + TicketCaseUpdate { + expected_content_digest: ticket_content_digest(&observed), + expected_state: TicketState::Open, + expected_closure_operation_key: None, + operation_key: "close-core".into(), + action: TicketCaseAction::Close { + reason: TicketClosureReason::Completed, + }, + explanation: "Done.".into(), + references: Vec::new(), + author: None, + }, ) .unwrap(); @@ -5616,7 +5353,7 @@ mod tests { let list = reopened.list(TicketListQuery::all()).unwrap(); assert_eq!(list.len(), 1); assert_eq!(list[0].id, created.id); - assert_eq!(list[0].workflow_state, TicketWorkflowState::Closed); + assert_eq!(list[0].workflow_state, TicketState::Closed); let ticket = reopened.show(TicketIdOrSlug::Id(created.id)).unwrap(); assert_eq!(ticket.meta.title, "SQLite Ticket"); assert!(ticket.events.iter().any(|event| { @@ -5649,38 +5386,6 @@ mod tests { ); } - fn assignment_source(role: &str) -> BTreeMap { - BTreeMap::from([ - ("source_runtime_id".to_owned(), "runtime-a".to_owned()), - ("source_worker_id".to_owned(), "worker-a".to_owned()), - ("source_actor_role".to_owned(), role.to_owned()), - ("source_assignment_id".to_owned(), "assignment-a".to_owned()), - ]) - } - - fn active_assignment_fixture(backend: &SqliteTicketBackend, ticket_id: &str) { - let conn = backend.open_connection().unwrap(); - conn.execute_batch( - "CREATE TABLE test_worker_assignments ( - workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, runtime_id TEXT NOT NULL, - worker_id TEXT NOT NULL, assignment_id TEXT NOT NULL, status TEXT NOT NULL - ); - CREATE VIEW ticket_active_worker_assignments AS - SELECT workspace_id, ticket_id, runtime_id, worker_id, assignment_id - FROM test_worker_assignments WHERE status = 'active';", - ) - .unwrap(); - conn.execute("INSERT INTO test_worker_assignments VALUES (?1, ?2, 'runtime-a', 'worker-a', 'assignment-a', 'active')", params![backend.workspace_id(), ticket_id]).unwrap(); - } - - fn release_assignment_fixture(backend: &SqliteTicketBackend) { - backend - .open_connection() - .unwrap() - .execute("UPDATE test_worker_assignments SET status = 'released'", []) - .unwrap(); - } - #[test] fn content_digest_matches_sqlite_and_ignores_event_order() { let tmp = TempDir::new().unwrap(); @@ -5751,1116 +5456,6 @@ mod tests { assert_eq!(ticket_content_digest(&reordered), digest); } - #[test] - fn unassigned_worker_actor_is_distinct_from_ticket_worker_requiring_assignment() { - for role in ["worker", "ticket_worker"] { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Actor boundary")) - .unwrap(); - let source = backend.clone().with_event_attributes(BTreeMap::from([ - ("source_actor_role".into(), role.into()), - ("source_runtime_id".into(), "runtime".into()), - ("source_worker_id".into(), "worker".into()), - ])); - let result = source.complete(&reference.id, completion_request(&ticket, "complete")); - if role == "worker" { - let completed = result.unwrap(); - assert_eq!( - completed - .events - .last() - .unwrap() - .attributes - .get("source_actor_role") - .map(String::as_str), - Some("worker") - ); - } else { - assert!(result.is_err()); - let reread = backend.show(reference.id.into()).unwrap(); - assert_eq!(reread.meta.workflow_state, ticket.meta.workflow_state); - assert_eq!(reread.events.len(), ticket.events.len()); - } - } - } - - #[test] - fn assignment_fence_accepts_exact_live_source_inside_state_and_close_transaction() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Active source")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - let source = backend.clone().with_event_attributes(assignment_source("ticket_worker")) - .with_mutation_hook(Arc::new(|conn, _| { - assert!(!conn.is_autocommit()); - let count: i64 = conn.query_row("SELECT COUNT(*) FROM ticket_active_worker_assignments WHERE runtime_id = 'runtime-a' AND worker_id = 'worker-a' AND assignment_id = 'assignment-a'", [], |row| row.get(0)).map_err(sqlite_err)?; - assert_eq!(count, 1); - Ok(()) - })); - let completed = source - .complete(&reference.id, completion_request(&ticket, "complete")) - .unwrap(); - source - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("done", "planning", "reopen", "reopen"), - ) - .unwrap(); - source - .close( - reference.id.clone().into(), - MarkdownText::new("Closed by active coder"), - ) - .unwrap(); - let closed = source.show(reference.id.clone().into()).unwrap(); - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - assert_eq!( - completed - .events - .last() - .unwrap() - .attributes - .get("source_assignment_id") - .map(String::as_str), - Some("assignment-a") - ); - } - - #[test] - fn assignment_fence_rejects_every_mismatched_identity_without_mutation() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Exact identity")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - for key in [ - "source_runtime_id", - "source_worker_id", - "source_assignment_id", - ] { - let mut attrs = assignment_source("ticket_worker"); - attrs.insert(key.to_owned(), "other".to_owned()); - let source = backend.clone().with_event_attributes(attrs); - assert!( - source - .complete(&reference.id, completion_request(&ticket, "complete")) - .is_err() - ); - assert!( - source - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", "finished", "finished") - ) - .is_err() - ); - assert!( - source - .close(reference.id.clone().into(), MarkdownText::new("finished")) - .is_err() - ); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - } - let (other, other_ticket) = backend - .create_with_snapshot(NewTicket::new("Different Ticket")) - .unwrap(); - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - assert!( - source - .complete(&other.id, completion_request(&other_ticket, "complete")) - .is_err() - ); - assert_eq!(backend.show(other.id.into()).unwrap(), other_ticket); - let other_workspace = - SqliteTicketBackend::open(backend.db_path(), "other-workspace").unwrap(); - let (other, other_ticket) = other_workspace - .create_with_snapshot(NewTicket::new("Different Workspace")) - .unwrap(); - let source = other_workspace - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - assert!( - source - .complete(&other.id, completion_request(&other_ticket, "complete")) - .is_err() - ); - assert_eq!(other_workspace.show(other.id.into()).unwrap(), other_ticket); - } - - #[test] - fn assignment_fence_rejects_missing_identity_or_active_view_and_non_coder_assignment_sources() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Missing identity")) - .unwrap(); - for key in [ - "source_runtime_id", - "source_worker_id", - "source_assignment_id", - ] { - let mut attrs = assignment_source("ticket_worker"); - attrs.remove(key); - let source = backend.clone().with_event_attributes(attrs); - assert!( - source - .complete(&reference.id, completion_request(&ticket, "complete")) - .is_err() - ); - } - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - assert!( - source - .complete(&reference.id, completion_request(&ticket, "complete")) - .is_err() - ); - active_assignment_fixture(&backend, &reference.id); - release_assignment_fixture(&backend); - let source = backend - .clone() - .with_event_attributes(assignment_source("orchestrator")); - assert!( - source - .complete(&reference.id, completion_request(&ticket, "complete")) - .is_err() - ); - assert_eq!(backend.show(reference.id.into()).unwrap(), ticket); - } - - #[test] - fn released_assignment_cannot_mutate_reopened_ticket_but_can_exactly_replay() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Released source")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - let request = completion_request(&ticket, "complete"); - let completed = source.complete(&reference.id, request.clone()).unwrap(); - release_assignment_fixture(&backend); - let mut orchestrator_attrs = assignment_source("orchestrator"); - orchestrator_attrs.remove("source_assignment_id"); - let orchestrator = backend.clone().with_event_attributes(orchestrator_attrs); - let reopened = orchestrator - .update_state( - &reference.id, - TicketStateUpdate { - state: TicketWorkflowState::Planning, - author: Some("registered-orchestrator".to_owned()), - ..completion_request(&completed, "reopen").into() - }, - ) - .unwrap(); - assert_eq!( - source.complete(&reference.id, request.clone()).unwrap(), - reopened - ); - assert_eq!( - &reopened.events[..completed.events.len()], - completed.events.as_slice() - ); - assert!( - source - .complete(&reference.id, completion_request(&reopened, "new-complete")) - .is_err() - ); - assert!( - source - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", "finished", "finished") - ) - .is_err() - ); - assert!( - source - .close(reference.id.clone().into(), MarkdownText::new("finished")) - .is_err() - ); - let close = TicketStateUpdate { - state: TicketWorkflowState::Closed, - ..completion_request(&reopened, "close").into() - }; - assert!(source.update_state(&reference.id, close).is_err()); - assert_eq!(backend.show(reference.id.into()).unwrap(), reopened); - let conn = backend.open_connection().unwrap(); - let status: String = conn - .query_row("SELECT status FROM test_worker_assignments", [], |row| { - row.get(0) - }) - .unwrap(); - assert_eq!(status, "released"); - } - - #[test] - fn assignment_source_actor_is_bound_to_replay_fingerprint() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Actor fingerprint")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - let request = completion_request(&ticket, "complete"); - let completed = source.complete(&reference.id, request.clone()).unwrap(); - release_assignment_fixture(&backend); - for key in [ - "source_runtime_id", - "source_worker_id", - "source_assignment_id", - "source_actor_role", - ] { - let mut attrs = assignment_source("ticket_worker"); - attrs.insert(key.to_owned(), "different-source".to_owned()); - let other_source = backend.clone().with_event_attributes(attrs); - assert!(matches!( - other_source.complete(&reference.id, request.clone()), - Err(TicketError::OperationFingerprintMismatch { .. }) - )); - } - assert_eq!(backend.show(reference.id.into()).unwrap(), completed); - } - - #[test] - fn legacy_state_and_close_share_transactional_audit_and_operation_receipts() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Legacy boundary")) - .unwrap(); - let body = MarkdownText::new("## Custom implementation report\n\nPreserved body.\n"); - let references = vec![TicketReference { - kind: "report".to_owned(), - target: "test-results".to_owned(), - }]; - let mut change = TicketStateChange::new("planning", "done", "finished", body.clone()); - change.references = references.clone(); - change.author = Some("legacy-author".to_owned()); - backend - .set_workflow_state(reference.id.clone().into(), change) - .unwrap(); - let done = backend.show(reference.id.clone().into()).unwrap(); - let event = done.events.last().unwrap(); - assert_eq!(event.body, body); - assert_eq!(event.references, references); - assert_eq!(event.author.as_deref(), Some("legacy-author")); - assert_eq!(event.from.as_deref(), Some("planning")); - assert_eq!(event.reason.as_deref(), Some("finished")); - assert_eq!( - event.attributes.get("expected_content_digest"), - Some(&ticket_content_digest(&ticket)) - ); - assert_eq!( - event - .attributes - .get("fingerprint_version") - .map(String::as_str), - Some("state-update-v2") - ); - let request = TicketStateUpdate { - expected_content_digest: ticket_content_digest(&ticket), - expected_state: TicketWorkflowState::Planning, - state: TicketWorkflowState::Done, - operation_key: event.attributes["operation_key"].clone(), - reason: "finished".to_owned(), - references, - author: Some("legacy-author".to_owned()), - }; - let replay = backend - .with_write(|conn| { - let snapshot = backend.load_ticket(conn, &reference.id)?; - backend.apply_state_update(conn, snapshot, request.clone(), body.clone()) - }) - .unwrap(); - assert_eq!(replay, done); - let resolution = - MarkdownText::new(format!("## Resolution\n\n{}\n", "details ".repeat(200))); - backend - .close(reference.id.clone().into(), resolution.clone()) - .unwrap(); - let closed = backend.show(reference.id.clone().into()).unwrap(); - let close = closed.events.last().unwrap(); - assert_eq!(close.kind, TicketEventKind::Close); - assert_eq!(close.from.as_deref(), Some("done")); - assert_eq!(close.to.as_deref(), Some("closed")); - assert_eq!(close.reason.as_deref(), Some("closed")); - assert_eq!(close.body, resolution); - assert_eq!(closed.resolution, Some(resolution)); - assert_eq!( - close.attributes.get("expected_content_digest"), - Some(&ticket_content_digest(&done)) - ); - assert_ne!( - close.attributes["operation_key"], - event.attributes["operation_key"] - ); - let conn = backend.open_connection().unwrap(); - let receipt_attributes = backend - .load_event_attributes( - &conn, - &reference.id, - close.attributes["event_sequence"].parse().unwrap(), - ) - .unwrap(); - assert_eq!(receipt_attributes.len(), 4); - let replay = backend - .with_write(|conn| { - let current = backend.load_ticket(conn, &reference.id)?; - backend.apply_state_update(conn, current, request, body) - }) - .unwrap(); - assert_eq!(replay, closed); - assert_eq!(&replay.events[..done.events.len()], done.events.as_slice()); - } - - #[test] - fn legacy_state_and_close_hooks_observe_new_state_and_rollback_common_boundary() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Legacy rollback")) - .unwrap(); - let failing = backend.clone().with_mutation_hook(Arc::new(|conn, event| { - assert!(!conn.is_autocommit()); - let (current, audited): (String, String) = conn.query_row("SELECT t.workflow_state, e.to_state FROM typed_tickets t JOIN typed_ticket_events e ON e.workspace_id = t.workspace_id AND e.ticket_id = t.ticket_id WHERE t.workspace_id = ?1 AND t.ticket_id = ?2 AND e.event_index = ?3", params![event.workspace_id, event.ticket_id, event.event_index], |row| Ok((row.get(0)?, row.get(1)?))).map_err(sqlite_err)?; - assert_eq!(current, audited); - Err(TicketError::Conflict("reject mutation".to_owned())) - })); - assert!( - failing - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", "finished", "custom body") - ) - .is_err() - ); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - assert!( - failing - .close(reference.id.clone().into(), MarkdownText::new("Resolution")) - .is_err() - ); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - let count: i64 = backend - .open_connection() - .unwrap() - .query_row( - "SELECT COUNT(*) FROM typed_ticket_event_attributes WHERE key = 'operation_key'", - [], - |row| row.get(0), - ) - .unwrap(); - assert_eq!(count, 0); - } - - #[test] - fn authenticated_receipt_context_replays_after_live_role_and_assignment_clearance() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Role clearance replay")) - .unwrap(); - active_assignment_fixture(&backend, &reference.id); - let source = backend - .clone() - .with_event_attributes(assignment_source("ticket_worker")); - let request = completion_request(&ticket, "complete"); - let completed = source.complete(&reference.id, request.clone()).unwrap(); - release_assignment_fixture(&backend); - let mut current_context = assignment_source("reader"); - current_context.remove("source_assignment_id"); - // Parent authenticates unchanged runtime/worker/author and operation before - // restoring only receipt role/assignment metadata from the recorded audit. - let receipt = completed.events.last().unwrap(); - for key in ["source_actor_role", "source_assignment_id"] { - current_context.insert(key.to_owned(), receipt.attributes[key].clone()); - } - let replay_source = backend.clone().with_event_attributes(current_context); - assert_eq!( - replay_source.complete(&reference.id, request).unwrap(), - completed - ); - assert!( - replay_source - .complete( - &reference.id, - completion_request(&completed, "new-complete") - ) - .is_err() - ); - assert_eq!(backend.show(reference.id.into()).unwrap(), completed); - } - - #[test] - fn common_state_mutation_preserves_actual_historical_ticket_completion_events() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let reference = backend - .create(NewTicket::new("Historical MR completion")) - .unwrap(); - let historical = backend.clone().with_event_attributes(BTreeMap::from([ - ( - "operation_key".to_owned(), - "historical-mr-completion".to_owned(), - ), - ("merge_request_id".to_owned(), "MR-existing".to_owned()), - ])); - let mut event = NewTicketEvent::new( - TicketEventKind::Other("ticket_completion".to_owned()), - "Historical Merge Request completion", - ); - event.references.push(TicketReference { - kind: "merge_request".to_owned(), - target: "MR-existing".to_owned(), - }); - historical - .add_event(reference.id.clone().into(), event) - .unwrap(); - let previous = backend.show(reference.id.clone().into()).unwrap(); - backend - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", "completed", "completed"), - ) - .unwrap(); - backend - .close(reference.id.clone().into(), MarkdownText::new("Resolved")) - .unwrap(); - let current = backend.show(reference.id.into()).unwrap(); - assert_eq!( - ¤t.events[..previous.events.len()], - previous.events.as_slice() - ); - assert_eq!( - current.events[1].kind, - TicketEventKind::Other("ticket_completion".to_owned()) - ); - } - - fn completion_request(ticket: &Ticket, key: &str) -> TicketCompletion { - TicketCompletion { - expected_content_digest: ticket_content_digest(ticket), - expected_state: ticket.meta.workflow_state, - operation_key: key.to_owned(), - reason: "Acceptance criteria met".to_owned(), - references: Vec::new(), - author: Some("tester".to_owned()), - } - } - - #[test] - fn frozen_counter_receipts_remain_immutable_and_cannot_be_reinterpreted_as_content_requests() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Old receipt")) - .unwrap(); - let request = completion_request(&ticket, "old-operation"); - backend.complete(&reference.id, request.clone()).unwrap(); - let connection = backend.open_connection().unwrap(); - // Frozen pre-content fixture. Its old fingerprint cannot prove a new - // content-based request, and migration must not rewrite immutable audit. - connection.execute( - "UPDATE typed_ticket_event_attributes SET value='state-update-v1' WHERE ticket_id=?1 AND key='fingerprint_version'", - [&reference.id], - ).unwrap(); - connection.execute( - "UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'", - [&reference.id], - ).unwrap(); - let before = backend.show(reference.id.clone().into()).unwrap(); - migrate_sqlite_ticket_schema(&connection).unwrap(); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); - assert!(matches!( - backend.complete(&reference.id, request), - Err(TicketError::OperationFingerprintMismatch { .. }), - )); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); - let fresh = backend - .complete( - &reference.id, - completion_request(&before, "fresh-operation"), - ) - .unwrap(); - assert_eq!(fresh.events.len(), before.events.len() + 1); - assert_eq!( - fresh - .events - .last() - .unwrap() - .attributes - .get("expected_content_digest"), - Some(&ticket_content_digest(&before)) - ); - } - - #[test] - fn completion_from_all_states_needs_no_git_targets_or_merge_request() { - let tmp = TempDir::new().unwrap(); - let backend = - SqliteTicketBackend::open(tmp.path().join("tickets.db"), "workspace-test").unwrap(); - for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - TicketWorkflowState::Done, - TicketWorkflowState::Closed, - ] { - let mut input = NewTicket::new(format!("Complete {state}")); - input.workflow_state = Some(state); - let (reference, ticket) = backend.create_with_snapshot(input).unwrap(); - let completed = backend - .complete(&reference.id, completion_request(&ticket, "complete")) - .unwrap(); - assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done); - assert_eq!(completed.meta.status, ExtensibleTicketStatus::Open); - assert_eq!(completed.meta.targets, ticket.meta.targets); - assert_eq!(completed.events.len(), ticket.events.len() + 1); - let event = completed.events.last().unwrap(); - assert_eq!(event.author.as_deref(), Some("tester")); - assert_eq!(event.from.as_deref(), Some(state.as_str())); - assert_eq!(event.to.as_deref(), Some("done")); - assert!(event.references.is_empty()); - assert_eq!(backend.show(reference.id.into()).unwrap(), completed); - } - } - - #[test] - fn completion_preserves_long_and_multiline_reasons_across_reload_and_replay() { - let tmp = TempDir::new().unwrap(); - let db = tmp.path().join("tickets.db"); - let backend = SqliteTicketBackend::open(&db, "workspace-test").unwrap(); - for reason in [ - "x".repeat(1025), - "日本語の完了理由を省略せず記録する。".repeat(1024), - "## 完了理由\r\n\n- 検証済み\n\n".repeat(1024), - ] { - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Long completion reason")) - .unwrap(); - let request = TicketCompletion { - reason: reason.clone(), - ..completion_request(&ticket, "complete") - }; - let completed = backend.complete(&reference.id, request.clone()).unwrap(); - assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done); - assert_eq!(completed.events.len(), ticket.events.len() + 1); - let event = completed.events.last().unwrap(); - assert_eq!(event.reason.as_deref(), Some(reason.as_str())); - assert_eq!(event.body.as_str(), reason); - - let reopened = SqliteTicketBackend::open(&db, "workspace-test").unwrap(); - assert_eq!( - reopened.show(reference.id.clone().into()).unwrap(), - completed - ); - assert_eq!( - reopened.complete(&reference.id, request.clone()).unwrap(), - completed - ); - let changed = TicketCompletion { - reason: format!("{reason}追加の判断"), - ..request - }; - assert!(matches!( - reopened.complete(&reference.id, changed), - Err(TicketError::OperationFingerprintMismatch { .. }) - )); - } - } - - #[test] - fn state_changes_and_close_preserve_long_markdown_reasons_and_bodies() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, _) = backend - .create_with_snapshot(NewTicket::new("Long state and close reasons")) - .unwrap(); - let reason = "## 判断\n\n長文の理由を記録する。\n".repeat(1024); - let body = "## 検証結果\n\n本文も省略しない。\n".repeat(1024); - backend - .set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "inprogress", reason.clone(), body.clone()), - ) - .unwrap(); - let current = backend.show(reference.id.clone().into()).unwrap(); - let event = current.events.last().unwrap(); - assert_eq!(event.reason.as_deref(), Some(reason.as_str())); - assert_eq!(event.body.as_str(), body); - let request = TicketStateUpdate { - state: TicketWorkflowState::Closed, - reason: reason.clone(), - ..completion_request(¤t, "close").into() - }; - let closed = backend - .update_state(&reference.id, request.clone()) - .unwrap(); - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - assert_eq!(closed.resolution, Some(MarkdownText::new(&reason))); - assert_eq!( - closed.events.last().unwrap().reason.as_deref(), - Some(reason.as_str()) - ); - assert_eq!( - backend.update_state(&reference.id, request).unwrap(), - closed - ); - } - - #[test] - fn completion_cas_rejects_stale_item_or_state_without_mutation() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend.create_with_snapshot(NewTicket::new("CAS")).unwrap(); - let request = completion_request(&ticket, "complete"); - let edited = backend - .edit_item( - reference.id.clone().into(), - TicketItemEdit { - title: Some("Edited".to_owned()), - ..Default::default() - }, - ) - .unwrap(); - assert!(matches!( - backend.complete(&reference.id, request), - Err(TicketError::StaleContent { .. }) - )); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), edited); - let mut request = completion_request(&edited, "complete"); - request.expected_state = TicketWorkflowState::Ready; - assert!(matches!( - backend.complete(&reference.id, request), - Err(TicketError::StaleWorkflowState { .. }) - )); - assert_eq!(backend.show(reference.id.into()).unwrap(), edited); - } - - #[test] - fn completion_exact_replay_survives_later_edits_reopen_and_backend_restart() { - let tmp = TempDir::new().unwrap(); - let db = tmp.path().join("tickets.db"); - let backend = SqliteTicketBackend::open(&db, "workspace-test").unwrap(); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Replay")) - .unwrap(); - let mut request = completion_request(&ticket, "complete"); - request.references = vec![TicketReference { - kind: "test_report".to_owned(), - target: "optional-evidence".to_owned(), - }]; - let completed = backend.complete("T-1", request.clone()).unwrap(); - assert_eq!( - completed.events.last().unwrap().references, - request.references - ); - let reopened = backend - .update_state( - &reference.id, - TicketStateUpdate { - state: TicketWorkflowState::Planning, - operation_key: "reopen".to_owned(), - ..completion_request(&completed, "unused").into() - }, - ) - .unwrap(); - let edited = backend - .edit_item( - reference.id.clone().into(), - TicketItemEdit { - body: Some(MarkdownText::new("New body")), - ..Default::default() - }, - ) - .unwrap(); - let backend = SqliteTicketBackend::open(&db, "workspace-test").unwrap(); - let replayed = backend.complete(&reference.id, request.clone()).unwrap(); - assert_eq!(replayed, edited); - assert_eq!(replayed.meta.workflow_state, TicketWorkflowState::Planning); - assert_eq!(replayed.document.body.as_str(), "New body"); - assert_eq!( - &replayed.events[..completed.events.len()], - completed.events.as_slice() - ); - let original_receipt = replayed - .events - .iter() - .find(|event| event.attributes.get("operation_key") == Some(&request.operation_key)) - .unwrap(); - assert_eq!(original_receipt, completed.events.last().unwrap()); - assert_eq!(replayed.events.len(), edited.events.len()); - assert_eq!(backend.show(reference.id.into()).unwrap(), edited); - assert_eq!(reopened.meta.workflow_state, TicketWorkflowState::Planning); - } - - #[test] - fn state_operation_receipts_store_constant_metadata_without_ticket_snapshots() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let mut input = NewTicket::new("Linear receipt storage"); - input.body = MarkdownText::new("Large authoritative item body. ".repeat(2000)); - let (reference, mut current) = backend.create_with_snapshot(input).unwrap(); - let first_request = completion_request(¤t, "complete-0"); - let operations = 16; - for index in 0..operations { - let request = completion_request(¤t, &format!("complete-{index}")); - current = backend.complete(&reference.id, request).unwrap(); - } - let conn = backend.open_connection().unwrap(); - let (count, bytes): (i64, i64) = conn.query_row( - "SELECT COUNT(*), COALESCE(SUM(length(value)), 0) FROM typed_ticket_event_attributes WHERE workspace_id = ?1 AND ticket_id = ?2", - params![backend.workspace_id(), reference.id], |row| Ok((row.get(0)?, row.get(1)?)), - ).unwrap(); - assert_eq!(count, operations * 4); - assert!(bytes < operations * 256); - let original_receipt = current.events[1].clone(); - let replayed = backend.complete(&reference.id, first_request).unwrap(); - assert_eq!(replayed, current); - assert_eq!(replayed.events[1], original_receipt); - assert_eq!(replayed.events.len(), operations as usize + 1); - let count_after: i64 = conn.query_row( - "SELECT COUNT(*) FROM typed_ticket_event_attributes WHERE workspace_id = ?1 AND ticket_id = ?2", - params![backend.workspace_id(), reference.id], |row| row.get(0), - ).unwrap(); - assert_eq!(count_after, count); - } - - #[test] - fn completion_fingerprint_binds_every_request_field_and_operation_kind() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Fingerprint")) - .unwrap(); - let request = completion_request(&ticket, "complete"); - let completed = backend.complete(&reference.id, request.clone()).unwrap(); - let variants = [ - TicketCompletion { - reason: "Changed reason".to_owned(), - ..request.clone() - }, - TicketCompletion { - author: Some("other".to_owned()), - ..request.clone() - }, - TicketCompletion { - author: None, - ..request.clone() - }, - TicketCompletion { - expected_content_digest: "other-content".to_owned(), - ..request.clone() - }, - TicketCompletion { - expected_state: TicketWorkflowState::Ready, - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: "report".to_owned(), - target: "different".to_owned(), - }], - ..request.clone() - }, - ]; - for changed in variants { - assert!(matches!( - backend.complete(&reference.id, changed), - Err(TicketError::OperationFingerprintMismatch { .. }) - )); - } - let close = TicketStateUpdate { - state: TicketWorkflowState::Closed, - ..request.into() - }; - assert!(matches!( - backend.update_state(&reference.id, close), - Err(TicketError::OperationFingerprintMismatch { .. }) - )); - assert_eq!(backend.show(reference.id.into()).unwrap(), completed); - } - - #[test] - fn audited_close_reopen_preserves_resolution_history_and_resource_identity() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Close reopen")) - .unwrap(); - let close = TicketStateUpdate { - state: TicketWorkflowState::Closed, - ..completion_request(&ticket, "close").into() - }; - let closed = backend.update_state(&reference.id, close.clone()).unwrap(); - assert_eq!(closed.meta.status, ExtensibleTicketStatus::Closed); - assert_eq!(closed.resolution, Some(MarkdownText::new(&close.reason))); - assert_eq!(closed.events.last().unwrap().kind, TicketEventKind::Close); - assert_eq!(closed.events.last().unwrap().author, close.author); - let reopen = TicketStateUpdate { - state: TicketWorkflowState::Planning, - ..completion_request(&closed, "reopen").into() - }; - let reopened = backend.update_state(&reference.id, reopen.clone()).unwrap(); - assert_eq!(reopened.meta.status, ExtensibleTicketStatus::Open); - assert_eq!(reopened.meta.resource_key, closed.meta.resource_key); - assert_eq!(reopened.resolution, closed.resolution); - assert_eq!( - &reopened.events[..closed.events.len()], - closed.events.as_slice() - ); - let replayed = backend.update_state(&reference.id, close).unwrap(); - assert_eq!(replayed, reopened); - assert_eq!(replayed.meta.workflow_state, TicketWorkflowState::Planning); - assert_eq!( - &replayed.events[..closed.events.len()], - closed.events.as_slice() - ); - assert_eq!( - backend.update_state(&reference.id, reopen).unwrap(), - reopened - ); - assert_eq!(backend.show(reference.id.into()).unwrap(), reopened); - } - - #[test] - fn completion_mutation_hook_failure_rolls_back_state_event_and_replay() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Rollback")) - .unwrap(); - let request = completion_request(&ticket, "complete"); - let failing = backend.clone().with_mutation_hook(Arc::new(|_, _| { - Err(TicketError::Conflict("reject audit".to_owned())) - })); - assert!(failing.complete(&reference.id, request.clone()).is_err()); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - let completed = backend.complete(&reference.id, request.clone()).unwrap(); - assert_eq!(backend.complete(&reference.id, request).unwrap(), completed); - } - - #[test] - fn completion_concurrent_exact_replay_appends_one_event() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Concurrent")) - .unwrap(); - let request = completion_request(&ticket, "complete"); - let barrier = Arc::new(std::sync::Barrier::new(2)); - let handles = (0..2) - .map(|_| { - let backend = backend.clone(); - let reference = reference.clone(); - let request = request.clone(); - let barrier = barrier.clone(); - std::thread::spawn(move || { - barrier.wait(); - backend.complete(&reference.id, request).unwrap() - }) - }) - .collect::>(); - let results = handles - .into_iter() - .map(|handle| handle.join().unwrap()) - .collect::>(); - assert_eq!(results[0], results[1]); - assert_eq!(results[0].events.len(), ticket.events.len() + 1); - assert_eq!(backend.show(reference.id.into()).unwrap(), results[0]); - } - - #[test] - fn content_digest_tracks_targets_and_restored_content_not_comments_or_state() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Content")) - .unwrap(); - let digest = ticket_content_digest(&ticket); - backend - .add_event( - reference.id.clone().into(), - NewTicketEvent::new(TicketEventKind::Comment, "comment"), - ) - .unwrap(); - let commented = backend.show(reference.id.clone().into()).unwrap(); - assert_eq!(ticket_content_digest(&commented), digest); - let completed = backend - .complete(&reference.id, completion_request(&ticket, "complete")) - .unwrap(); - assert_eq!(ticket_content_digest(&completed), digest); - backend - .edit_item( - reference.id.clone().into(), - TicketItemEdit { - targets: Some(TicketTargetsEdit::Set { - targets: vec![write_target("main")], - }), - ..Default::default() - }, - ) - .unwrap(); - let restored = backend - .edit_item( - reference.id.into(), - TicketItemEdit { - targets: Some(TicketTargetsEdit::Clear), - ..Default::default() - }, - ) - .unwrap(); - assert_eq!(ticket_content_digest(&restored), digest); - } - - #[test] - fn completion_rejects_invalid_requests_without_recording_operation_key() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Validation")) - .unwrap(); - let request = completion_request(&ticket, "complete"); - for invalid in [ - TicketCompletion { - operation_key: " ".to_owned(), - ..request.clone() - }, - TicketCompletion { - expected_content_digest: "".to_owned(), - ..request.clone() - }, - TicketCompletion { - reason: "".to_owned(), - ..request.clone() - }, - TicketCompletion { - reason: " \t ".to_owned(), - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: " \t ".to_owned(), - target: "report".to_owned(), - }], - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: "report".to_owned(), - target: " \t ".to_owned(), - }], - ..request.clone() - }, - TicketCompletion { - author: Some("".to_owned()), - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: "".to_owned(), - target: "report".to_owned(), - }], - ..request.clone() - }, - TicketCompletion { - references: vec![TicketReference { - kind: "report".to_owned(), - target: "".to_owned(), - }], - ..request.clone() - }, - ] { - assert!(matches!( - backend.complete(&reference.id, invalid), - Err(TicketError::Conflict(_)) - )); - assert_eq!(backend.show(reference.id.clone().into()).unwrap(), ticket); - } - assert!(backend.complete(&reference.id, request).is_ok()); - } - - #[test] - fn legacy_state_and_close_reject_blank_reasons_resolution_and_references() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Legacy validation")) - .unwrap(); - assert!(matches!( - backend.set_workflow_state( - reference.id.clone().into(), - TicketStateChange::new("planning", "done", " \t ", "finished") - ), - Err(TicketError::Conflict(_)) - )); - let mut change = TicketStateChange::new("planning", "done", "finished", "finished"); - change.references.push(TicketReference { - kind: "report".to_owned(), - target: " \t ".to_owned(), - }); - assert!(matches!( - backend.set_workflow_state(reference.id.clone().into(), change), - Err(TicketError::Conflict(_)) - )); - assert!(matches!( - backend.close(reference.id.clone().into(), MarkdownText::new(" \n\t ")), - Err(TicketError::Conflict(_)) - )); - assert_eq!(backend.show(reference.id.into()).unwrap(), ticket); - } - - #[test] - fn completion_and_state_update_dispatch_round_trip_the_shared_api() { - let tmp = TempDir::new().unwrap(); - let backend = crate::tool::TicketToolBackend::new(backend(&tmp)); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("Dispatch")) - .unwrap(); - let operation = TicketBackendOperation::Complete { - ticket: "T-1".to_owned(), - request: completion_request(&ticket, "complete"), - }; - let encoded = serde_json::to_string(&operation).unwrap(); - let operation = serde_json::from_str(&encoded).unwrap(); - let TicketBackendOperationResult::Ticket(completed) = - execute_ticket_backend_operation(&backend, operation).unwrap() - else { - panic!("expected Ticket") - }; - assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done); - let operation = TicketBackendOperation::UpdateState { - ticket: reference.id, - request: TicketStateUpdate { - state: TicketWorkflowState::Closed, - ..completion_request(&completed, "close").into() - }, - }; - let encoded = serde_json::to_string(&operation).unwrap(); - let operation = serde_json::from_str(&encoded).unwrap(); - let TicketBackendOperationResult::Ticket(closed) = - execute_ticket_backend_operation(&backend, operation).unwrap() - else { - panic!("expected Ticket") - }; - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - } - #[test] fn queue_assignment_fence_names_only_the_explicit_ticket() { let tmp = TempDir::new().unwrap(); @@ -6908,99 +5503,6 @@ mod tests { ); } - #[test] - fn state_field_rejects_removed_phase_path_without_mutation() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let (reference, ticket) = backend - .create_with_snapshot(NewTicket::new("No phase")) - .unwrap(); - assert!( - backend - .set_state_field( - reference.id.clone().into(), - "phase", - TicketStateChange::new("planning", "done", "finished", "finished") - ) - .is_err() - ); - assert_eq!(backend.show(reference.id.into()).unwrap(), ticket); - } - - #[test] - fn state_defaults_and_queue_transition_round_trip() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let planning = backend.create(NewTicket::new("Default planning")).unwrap(); - let planning = backend.show(TicketIdOrSlug::Id(planning.id)).unwrap(); - assert_eq!(planning.meta.workflow_state, TicketWorkflowState::Planning); - assert!(planning.meta.workflow_state_explicit); - - let mut closed_input = NewTicket::new("Closed state"); - closed_input.workflow_state = Some(TicketWorkflowState::Closed); - let closed = backend.create(closed_input).unwrap(); - let closed = backend.show(TicketIdOrSlug::Id(closed.id)).unwrap(); - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - assert!(closed.meta.workflow_state_explicit); - - let mut ready_input = NewTicket::new("Ready Workflow"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); - ready_input.targets = vec![ticket_target( - "main", - Some("develop"), - TicketTargetAccess::ReadWrite, - )]; - let ready = backend.create(ready_input).unwrap(); - backend - .queue_ready(TicketIdOrSlug::Id(ready.id.clone()), "workspace-panel") - .unwrap(); - - let queued = backend.show(TicketIdOrSlug::Id(ready.id)).unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); - assert!(queued.meta.workflow_state_explicit); - assert_eq!(queued.meta.queued_by.as_deref(), Some("workspace-panel")); - assert!(queued.meta.queued_at.is_some()); - let event = queued - .events - .iter() - .find(|event| event.kind == TicketEventKind::StateChanged) - .unwrap(); - assert_eq!(event.state_field.as_deref(), Some("state")); - assert_eq!(event.from.as_deref(), Some("ready")); - assert_eq!(event.to.as_deref(), Some("queued")); - assert_eq!(event.reason.as_deref(), Some("queued")); - assert!(!event.attributes.contains_key("targets")); - assert_eq!(queued.meta.targets[0].repository_key, "main"); - assert!(!event.attributes.contains_key("repository_id")); - assert!(!event.attributes.contains_key("ref_selector")); - } - - #[test] - fn queue_accepts_all_states_without_targets() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - TicketWorkflowState::Done, - TicketWorkflowState::Closed, - ] { - let mut input = NewTicket::new(format!("Queue {state}")); - input.workflow_state = Some(state); - let ticket = backend.create(input).unwrap(); - let outcome = backend - .queue_ready(ticket.id.clone().into(), "actor") - .unwrap(); - assert_eq!(outcome.queued_tickets, vec![ticket.id.clone()]); - let current = backend.show(ticket.id.into()).unwrap(); - assert_eq!(current.meta.workflow_state, TicketWorkflowState::Queued); - assert_eq!(current.meta.status, ExtensibleTicketStatus::Open); - assert_eq!(current.events.last().unwrap().from, Some(state.to_string())); - } - } - #[test] fn mark_ready_resolves_target_and_is_idempotent() { let tmp = TempDir::new().unwrap(); @@ -7024,7 +5526,7 @@ mod tests { let second = backend .mark_ready(TicketIdOrSlug::Id(ticket.id.clone()), request) .unwrap(); - assert_eq!(first.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(first.meta.workflow_state, TicketState::Open); assert_eq!( first.meta.targets, vec![ @@ -7062,8 +5564,9 @@ mod tests { .iter() .filter(|event| { event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("planning") - && event.to.as_deref() == Some("ready") + && event.from.as_deref() == Some("open") + && event.to.as_deref() == Some("open") + && event.state_field.as_deref() == Some("readiness") }) .count(), 1 @@ -7113,7 +5616,7 @@ mod tests { .unwrap(); assert_eq!(ready.meta.targets[0].access, TicketTargetAccess::ReadOnly); let unchanged = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); - assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(unchanged.meta.workflow_state, TicketState::Open); let mut multiple_writes = NewTicket::new("Multiple write targets"); multiple_writes.targets = vec![write_target("main"), write_target("docs")]; @@ -7129,7 +5632,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(ready.meta.workflow_state, TicketState::Open); assert_eq!( ready .meta @@ -7146,12 +5649,12 @@ mod tests { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - TicketWorkflowState::Done, - TicketWorkflowState::Closed, + TicketState::Open, + TicketState::Open, + TicketState::Open, + TicketState::Open, + TicketState::Closed, + TicketState::Closed, ] { let mut input = NewTicket::new(format!("Targets {state}")); input.workflow_state = Some(state); @@ -7184,39 +5687,12 @@ mod tests { } } - #[test] - fn close_sets_state_closed() { - let tmp = TempDir::new().unwrap(); - let backend = backend(&tmp); - let input = NewTicket::new("Close Workflow"); - let ticket = backend.create(input).unwrap(); - - backend - .close( - TicketIdOrSlug::Id(ticket.id.clone()), - MarkdownText::new("Completed."), - ) - .unwrap(); - let record = backend.show(TicketIdOrSlug::Id(ticket.id)).unwrap(); - assert_eq!(record.meta.status, ExtensibleTicketStatus::Closed); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Closed); - let close = record.events.last().unwrap(); - assert_eq!(close.kind, TicketEventKind::Close); - assert_eq!(close.from.as_deref(), Some("planning")); - assert_eq!(close.to.as_deref(), Some("closed")); - assert_eq!( - close.attributes.get("expected_content_digest"), - Some(&ticket_content_digest(&record)) - ); - assert!(close.attributes.contains_key("operation_key")); - } - #[test] fn ticket_relations_store_forward_and_derive_inverse_blockers() { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let mut ready_input = NewTicket::new("Ready Relation Source"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); + ready_input.workflow_state = Some(TicketState::Open); let source = backend.create(ready_input).unwrap(); let target = backend .create(NewTicket::new("Planning Dependency")) @@ -7266,11 +5742,11 @@ mod tests { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let mut waiter_input = NewTicket::new("Ready After Queued Dependency"); - waiter_input.workflow_state = Some(TicketWorkflowState::Ready); + waiter_input.workflow_state = Some(TicketState::Open); waiter_input.targets = vec![write_target("main")]; let waiter = backend.create(waiter_input).unwrap(); let mut dependency_input = NewTicket::new("Queued Dependency"); - dependency_input.workflow_state = Some(TicketWorkflowState::Queued); + dependency_input.workflow_state = Some(TicketState::Open); let dependency = backend.create(dependency_input).unwrap(); backend .add_ticket_relation( @@ -7288,15 +5764,15 @@ mod tests { .queue_ready(TicketIdOrSlug::Id(waiter.id.clone()), "test") .unwrap(); let queued = backend.show(TicketIdOrSlug::Id(waiter.id.clone())).unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(queued.meta.workflow_state, TicketState::Open); assert_eq!(queued.meta.queued_by.as_deref(), Some("test")); let mut incoming_input = NewTicket::new("Ready After Inprogress Blocker"); - incoming_input.workflow_state = Some(TicketWorkflowState::Ready); + incoming_input.workflow_state = Some(TicketState::Open); incoming_input.targets = vec![write_target("main")]; let incoming = backend.create(incoming_input).unwrap(); let mut blocker_input = NewTicket::new("Inprogress Blocker"); - blocker_input.workflow_state = Some(TicketWorkflowState::InProgress); + blocker_input.workflow_state = Some(TicketState::Open); let blocker = backend.create(blocker_input).unwrap(); backend .add_ticket_relation( @@ -7316,10 +5792,7 @@ mod tests { let queued_incoming = backend .show(TicketIdOrSlug::Id(incoming.id.clone())) .unwrap(); - assert_eq!( - queued_incoming.meta.workflow_state, - TicketWorkflowState::Queued - ); + assert_eq!(queued_incoming.meta.workflow_state, TicketState::Open); } #[test] @@ -7327,7 +5800,7 @@ mod tests { let tmp = TempDir::new().unwrap(); let backend = backend(&tmp); let mut blocked_input = NewTicket::new("Blocked Ready"); - blocked_input.workflow_state = Some(TicketWorkflowState::Ready); + blocked_input.workflow_state = Some(TicketState::Open); let blocked = backend.create(blocked_input).unwrap(); let dependency = backend.create(NewTicket::new("Dependency")).unwrap(); backend @@ -7348,7 +5821,7 @@ mod tests { let unchanged = backend .show(TicketIdOrSlug::Id(blocked.id.clone())) .unwrap(); - assert_eq!(unchanged.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(unchanged.meta.workflow_state, TicketState::Open); assert_eq!(unchanged.relations.blockers.len(), 1); assert_eq!( unchanged.relations.blockers[0].blocking_ticket, @@ -7356,7 +5829,7 @@ mod tests { ); let mut incoming_input = NewTicket::new("Incoming Blocked Ready"); - incoming_input.workflow_state = Some(TicketWorkflowState::Ready); + incoming_input.workflow_state = Some(TicketState::Open); let incoming = backend.create(incoming_input).unwrap(); let blocker = backend.create(NewTicket::new("Blocker")).unwrap(); backend @@ -7377,10 +5850,7 @@ mod tests { let unchanged_incoming = backend .show(TicketIdOrSlug::Id(incoming.id.clone())) .unwrap(); - assert_eq!( - unchanged_incoming.meta.workflow_state, - TicketWorkflowState::Queued - ); + assert_eq!(unchanged_incoming.meta.workflow_state, TicketState::Open); assert_eq!(unchanged_incoming.relations.blockers.len(), 1); assert_eq!( unchanged_incoming.relations.blockers[0].blocking_ticket, diff --git a/crates/ticket/src/sqlite_schema.rs b/crates/ticket/src/sqlite_schema.rs index 6b8f3c990..40bd2905a 100644 --- a/crates/ticket/src/sqlite_schema.rs +++ b/crates/ticket/src/sqlite_schema.rs @@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err}; const MIGRATION_TABLE: &str = "ticket_schema_migrations"; const MAX_SCHEMA_DIAGNOSTICS: usize = 32; -const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7; +const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 9; const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6; const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline"; @@ -52,6 +52,9 @@ const OWNED_TABLES: &[&str] = &[ "typed_ticket_relations", "typed_ticket_orchestration_plans", "typed_ticket_artifacts", + "typed_ticket_case_metadata", + "typed_ticket_case_history", + "typed_ticket_legacy_workflow_snapshot", "workspace_resource_keys", ]; @@ -290,6 +293,7 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { )]) { migrate_v6_to_v7(connection)?; + migrate_v7_to_v9(connection)?; connection .execute( "DELETE FROM ticket_schema_migrations WHERE version = ?1", @@ -307,7 +311,11 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> { ], ) .map_err(sqlite_err)?; + } else if applied == BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_string())]) { + migrate_v7_to_v9(connection)?; + connection.execute("UPDATE ticket_schema_migrations SET version = 9, applied_at = ?1 WHERE version = 7", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?; } else { + // Version 8 belongs to T-740; compose its tag migration explicitly at integration. validate_applied_migrations(&applied)?; } @@ -448,6 +456,63 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> { collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics); } + for (table, columns) in [ + ( + "typed_ticket_case_metadata", + vec![ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("closure_operation_key", "TEXT", false, 0), + ], + ), + ( + "typed_ticket_case_history", + vec![ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("ordinal", "INTEGER", true, 0), + column("operation_key", "TEXT", true, 3), + column("request_fingerprint", "TEXT", true, 0), + column("judgment_json", "TEXT", true, 0), + ], + ), + ( + "typed_ticket_legacy_workflow_snapshot", + vec![ + column("workspace_id", "TEXT", true, 1), + column("ticket_id", "TEXT", true, 2), + column("snapshot_json", "TEXT", true, 0), + ], + ), + ] { + if let Err(error) = verify_table(connection, table, &columns, CHILD_FOREIGN_KEYS, false) { + push_diagnostic(&mut diagnostics, error.to_string()); + } + } + for name in [ + "typed_ticket_case_history_no_update", + "typed_ticket_case_history_no_delete", + "typed_ticket_legacy_workflow_no_update", + "typed_ticket_legacy_workflow_no_delete", + "typed_ticket_case_state_insert", + "typed_ticket_case_state_update", + ] { + let exists = connection + .query_row( + "SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = ?1", + params![name], + |_| Ok(()), + ) + .optional() + .map_err(sqlite_err)? + .is_some(); + if !exists { + push_diagnostic( + &mut diagnostics, + format!("missing Ticket case trigger {name}"), + ); + } + } for table in OWNED_TABLES { collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics); } @@ -474,6 +539,85 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> { .map_err(sqlite_err) } +/// Independent 7→9 case migration. Does not rewrite historical events or +/// create/resume worker identities. Version 8 tag composition is deliberately separate. +fn migrate_v7_to_v9(connection: &Connection) -> Result<()> { + let snapshots = { + let mut stmt = connection.prepare("SELECT workspace_id, ticket_id, workflow_state, workflow_state_explicit, status, queued_by, queued_at, resolution FROM typed_tickets").map_err(sqlite_err)?; + stmt.query_map([], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + crate::TicketLegacyWorkflowSnapshot { + workflow_state: row.get(2)?, + workflow_state_explicit: row.get::<_, i64>(3)? != 0, + status: row.get(4)?, + queued_by: row.get(5)?, + queued_at: row.get(6)?, + resolution: row.get(7)?, + }, + )) + }) + .map_err(sqlite_err)? + .collect::, _>>() + .map_err(sqlite_err)? + }; + // Validate old authority before any writes; unknown states are not silently repaired. + for (_, _, snapshot) in &snapshots { + if !matches!( + snapshot.workflow_state.as_str(), + "planning" | "ready" | "queued" | "inprogress" | "done" | "closed" + ) { + return Err(TicketError::Sqlite(format!( + "unsupported legacy Ticket workflow state {:?}", + snapshot.workflow_state + ))); + } + } + // State guards are installed only after all six-state rows have been converted. + for (workspace_id, ticket_id, snapshot) in &snapshots { + let state = if matches!(snapshot.workflow_state.as_str(), "done" | "closed") { + "closed" + } else { + "open" + }; + connection.execute("UPDATE typed_tickets SET workflow_state = ?3, status = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2", params![workspace_id, ticket_id, state]).map_err(sqlite_err)?; + } + connection + .execute_batch(include_str!("case_schema.sql")) + .map_err(sqlite_err)?; + for (workspace_id, ticket_id, snapshot) in snapshots { + connection.execute("INSERT INTO typed_ticket_legacy_workflow_snapshot (workspace_id, ticket_id, snapshot_json) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, serde_json::to_string(&snapshot).map_err(sqlite_err)?]).map_err(sqlite_err)?; + if matches!(snapshot.workflow_state.as_str(), "done" | "closed") { + let key = format!("migration-v9:{ticket_id}"); + let judgment = crate::TicketCaseHistoryEntry { + operation_key: key.clone(), + previous_closure_operation_key: None, + // No typed evidence in schema7 establishes Completed. `done` is not success. + action: crate::TicketCaseAction::Close { + reason: crate::TicketClosureReason::Unknown, + }, + explanation: snapshot + .resolution + .clone() + .filter(|s| !s.trim().is_empty()) + .unwrap_or_else(|| { + format!( + "Migrated legacy {} state; closure reason is unknown.", + snapshot.workflow_state + ) + }), + references: Vec::new(), + author: "ticket-schema-migration-v9".into(), + at: chrono::Utc::now().to_rfc3339(), + }; + connection.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) VALUES (?1, ?2, 0, ?3, 'migration-v9', ?4)", params![workspace_id, ticket_id, key, serde_json::to_string(&judgment).map_err(sqlite_err)?]).map_err(sqlite_err)?; + connection.execute("INSERT INTO typed_ticket_case_metadata (workspace_id, ticket_id, closure_operation_key) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, key]).map_err(sqlite_err)?; + } + } + Ok(()) +} + fn migrate_v6_to_v7(connection: &Connection) -> Result<()> { connection .execute_batch( @@ -1026,7 +1170,9 @@ mod tests { #[test] fn migrates_single_target_rows_to_ordered_read_write_targets() { let connection = Connection::open_in_memory().unwrap(); - create_latest_ticket_schema(&connection).unwrap(); + connection + .execute_batch(include_str!("../tests/fixtures/schema7.sql")) + .unwrap(); connection .execute_batch( "DROP TABLE typed_ticket_targets; @@ -1137,7 +1283,7 @@ mod tests { assert!(!migrated_attributes.contains_key("ref_selector")); assert_eq!( load_applied_migrations(&connection).unwrap(), - BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())]) + BTreeMap::from([(9, BASELINE_MIGRATION_NAME.to_owned())]) ); } @@ -1155,7 +1301,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 7 baseline marker" + "migration history must contain only the canonical version 9 baseline marker" )); assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2); } @@ -1177,7 +1323,7 @@ mod tests { let error = migrate_sqlite_ticket_schema(&connection).unwrap_err(); assert!(error.to_string().contains( - "migration history must contain only the canonical version 7 baseline marker" + "migration history must contain only the canonical version 9 baseline marker" )); assert!(!table_exists(&connection, "typed_tickets").unwrap()); } diff --git a/crates/ticket/src/tool.rs b/crates/ticket/src/tool.rs index 7b5a2f310..b0b1fe8e3 100644 --- a/crates/ticket/src/tool.rs +++ b/crates/ticket/src/tool.rs @@ -17,7 +17,7 @@ use crate::{ Ticket, TicketBackend, TicketBodyReplacement, TicketDoctorDiagnostic, TicketDoctorReport, TicketDoctorSeverity, TicketError, TicketEventKind, TicketIdOrSlug, TicketIntakeSummary, TicketListState, TicketMarkReady, TicketRef, TicketRelation, TicketRelationKind, - TicketRelationView, TicketStateChange, TicketSummary, TicketWorkflowState, default_author, + TicketRelationView, TicketState, TicketStateChange, TicketSummary, default_author, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -33,7 +33,7 @@ const MAX_BODY_MAX_BYTES: usize = 64 * 1024; const DEFAULT_DIAGNOSTIC_LIMIT: usize = 100; const MAX_DIAGNOSTIC_LIMIT: usize = 500; -pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [ +pub const TICKET_BASE_TOOL_NAMES: [&str; 13] = [ "TicketCreate", "TicketEditItem", "QueryTicket", @@ -44,8 +44,7 @@ pub const TICKET_BASE_TOOL_NAMES: [&str; 14] = [ "TicketImplementationReport", "TicketMarkReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketDoctor", ]; @@ -68,7 +67,7 @@ pub const TICKET_ORCHESTRATION_TOOL_NAMES: [&str; 5] = [ pub const TICKET_ORCHESTRATION_READ_ONLY_TOOL_NAMES: [&str; 2] = ["TicketRelationQuery", "TicketOrchestrationPlanQuery"]; -pub const TICKET_TOOL_NAMES: [&str; 20] = [ +pub const TICKET_TOOL_NAMES: [&str; 19] = [ "TicketCreate", "TicketEditItem", "QueryTicket", @@ -80,8 +79,7 @@ pub const TICKET_TOOL_NAMES: [&str; 20] = [ "TicketMarkReady", "TicketIntakeReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketDoctor", "TicketRelationRecord", @@ -100,7 +98,7 @@ pub const TICKET_READ_ONLY_TOOL_NAMES: [&str; 6] = [ "TicketOrchestrationPlanQuery", ]; -pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [ +pub const TICKET_MUTATING_TOOL_NAMES: [&str; 13] = [ "TicketCreate", "TicketEditItem", "TicketComment", @@ -110,8 +108,7 @@ pub const TICKET_MUTATING_TOOL_NAMES: [&str; 14] = [ "TicketMarkReady", "TicketIntakeReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", @@ -133,18 +130,15 @@ const PLAN_DESCRIPTION: &str = "Append a typed Ticket plan event. `body` is Mark const DECISION_DESCRIPTION: &str = "Append a typed Ticket decision event. `body` is Markdown."; const IMPLEMENTATION_REPORT_DESCRIPTION: &str = "Append a typed Ticket implementation_report event. `body` is Markdown."; -const MARK_READY_DESCRIPTION: &str = "Mark a planning Ticket ready through the typed Ticket backend. \ +const MARK_READY_DESCRIPTION: &str = "Mark an open Ticket ready for routing through the typed Ticket backend. \ Targets are optional; provided targets are validated against the Workspace catalog without Git resolution. \ -Records one typed state_changed event and transitions planning -> ready. `reason` is optional."; -const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark a planning Ticket ready. \ +Records one typed state_changed event and leaves the case open. `reason` is optional."; +const INTAKE_READY_DESCRIPTION: &str = "Record a bounded intake summary and mark an open Ticket ready for routing. \ The backend applies the same optional catalog targets validation as TicketMarkReady and commits the summary, \ -state_changed event, effective targets, and planning -> ready transition atomically."; +state_changed event, effective targets, and routing readiness update atomically."; const QUEUE_DESCRIPTION: &str = "Queue only the explicitly requested Ticket for Orchestrator routing through the typed \ -Ticket backend, regardless of state or dependencies. Dependency diagnostics are retained; no dependencies are auto-queued."; -const WORKFLOW_STATE_DESCRIPTION: &str = "Change Ticket state with a bounded audited state_changed event. \ -State and dependency metadata do not impose an implementation phase path. Reopening does not revive released work identities."; -const CLOSE_DESCRIPTION: &str = "Close a Ticket with a Markdown resolution through the typed Ticket \ -backend. The backend sets the Ticket state to closed and appends a close event."; +Ticket backend, when the case is open, regardless of dependencies. Dependency diagnostics are retained; no dependencies are auto-queued."; +const CASE_UPDATE_DESCRIPTION: &str = "Close, reopen or correct a Ticket closure judgment with typed reasons and content/state/observed-closure-key CAS. Exact input replay does not duplicate history. No Repository/MR prerequisite. Other reasons must be nonblank, NUL-free and at most 1024 UTF-8 bytes. Reopening never revives released work identities."; const RELATION_RECORD_DESCRIPTION: &str = "Record a forward typed Ticket-to-Ticket relation as durable \ project-level metadata. Supported kinds are depends_on, blocks, related, supersedes, and duplicate_of; \ inverse views are derived, not stored."; @@ -177,8 +171,7 @@ fn base_tool_description(name: &str) -> &'static str { "TicketMarkReady" => MARK_READY_DESCRIPTION, "TicketIntakeReady" => INTAKE_READY_DESCRIPTION, "TicketQueue" => QUEUE_DESCRIPTION, - "TicketWorkflowState" => WORKFLOW_STATE_DESCRIPTION, - "TicketClose" => CLOSE_DESCRIPTION, + "TicketCaseUpdate" => CASE_UPDATE_DESCRIPTION, "TicketRelationRecord" => RELATION_RECORD_DESCRIPTION, "TicketRelationRemove" => RELATION_REMOVE_DESCRIPTION, "TicketRelationQuery" => RELATION_QUERY_DESCRIPTION, @@ -301,33 +294,8 @@ impl TicketBackend for TicketToolBackend { self.backend.add_intake_summary(id, summary) } - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> TicketResult<()> { - self.backend.set_state_field(id, field, change) - } - - fn set_workflow_state( - &self, - id: TicketIdOrSlug, - change: TicketStateChange, - ) -> TicketResult<()> { - self.backend.set_workflow_state(id, change) - } - - fn update_state( - &self, - ticket: &str, - request: crate::TicketStateUpdate, - ) -> TicketResult { - self.backend.update_state(ticket, request) - } - - fn complete(&self, ticket: &str, request: crate::TicketCompletion) -> TicketResult { - self.backend.complete(ticket, request) + fn update_case(&self, ticket: &str, request: crate::TicketCaseUpdate) -> TicketResult { + self.backend.update_case(ticket, request) } fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> TicketResult { @@ -342,10 +310,6 @@ impl TicketBackend for TicketToolBackend { self.backend.queue_ready(id, queued_by) } - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> { - self.backend.close(id, resolution) - } - fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -412,9 +376,9 @@ struct TicketCreateParams { /// Optional risk flag frontmatter values. #[serde(default)] risk_flags: Vec, - /// Optional state frontmatter value. Defaults to `planning`. + /// Optional state frontmatter value. Defaults to `open`. #[serde(default)] - state: Option, + state: Option, /// Optional queued_at frontmatter value. #[serde(default)] queued_at: Option, @@ -449,34 +413,21 @@ struct TicketEditItemParams { #[derive(Debug, Clone, Copy, Deserialize, schemars::JsonSchema)] #[serde(rename_all = "snake_case")] -enum TicketWorkflowStateParam { - Planning, - Ready, - Queued, - Inprogress, - Done, +enum TicketStateParam { + Open, Closed, } -impl TicketWorkflowStateParam { - fn into_state(self) -> TicketWorkflowState { +impl TicketStateParam { + fn into_state(self) -> TicketState { match self { - Self::Planning => TicketWorkflowState::Planning, - Self::Ready => TicketWorkflowState::Ready, - Self::Queued => TicketWorkflowState::Queued, - Self::Inprogress => TicketWorkflowState::InProgress, - Self::Done => TicketWorkflowState::Done, - Self::Closed => TicketWorkflowState::Closed, + Self::Open => TicketState::Open, + Self::Closed => TicketState::Closed, } } - fn into_list_state(self) -> TicketListState { match self { - Self::Planning => TicketListState::Planning, - Self::Ready => TicketListState::Ready, - Self::Queued => TicketListState::Queued, - Self::Inprogress => TicketListState::InProgress, - Self::Done => TicketListState::Done, + Self::Open => TicketListState::Open, Self::Closed => TicketListState::Closed, } } @@ -486,11 +437,7 @@ impl TicketWorkflowStateParam { #[serde(rename_all = "snake_case")] enum QueryTicketStateParam { Active, - Planning, - Ready, - Queued, - Inprogress, - Done, + Open, Closed, All, } @@ -498,11 +445,7 @@ enum QueryTicketStateParam { impl QueryTicketStateParam { fn as_list_state(self) -> Option { match self { - Self::Planning => Some(TicketListState::Planning), - Self::Ready => Some(TicketListState::Ready), - Self::Queued => Some(TicketListState::Queued), - Self::Inprogress => Some(TicketListState::InProgress), - Self::Done => Some(TicketListState::Done), + Self::Open => Some(TicketListState::Open), Self::Closed => Some(TicketListState::Closed), Self::Active | Self::All => None, } @@ -511,12 +454,12 @@ impl QueryTicketStateParam { #[derive(Debug, Deserialize, schemars::JsonSchema)] struct QueryTicketParams { - /// State filter. Defaults to active Tickets (all non-closed states). Use `all` to include closed Tickets. + /// State filter. Defaults to active Tickets (open cases). Use `all` to include closed Tickets. #[serde(default)] state: Option, /// Explicit workflow-state filter list. Cannot be combined with `state`. #[serde(default)] - states: Option>, + states: Option>, /// Maximum number of summaries to return. Defaults to 50, max 100. #[serde(default)] limit: Option, @@ -606,25 +549,21 @@ struct TicketQueueParams { } #[derive(Debug, Deserialize, schemars::JsonSchema)] -struct TicketWorkflowStateParams { - /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. +#[serde(deny_unknown_fields)] +struct TicketCaseUpdateParams { + /// Ticket reference. Prefer T-*. ticket: String, - /// Expected current state. The backend rejects stale transitions. - from: TicketWorkflowStateParam, - /// Target state. - to: TicketWorkflowStateParam, - /// Reason attached to the typed state_changed event. - reason: String, - /// Markdown body for the typed state_changed event. - body: String, -} - -#[derive(Debug, Deserialize, schemars::JsonSchema)] -struct TicketCloseParams { - /// Ticket reference. Prefer `T-*`; canonical internal ids remain accepted for compatibility. - ticket: String, - /// Markdown resolution written to resolution.md and thread.md. - resolution: String, + expected_content_digest: String, + expected_state: TicketState, + /// Explicit null means no closure has been observed. Omission is rejected. + #[serde(deserialize_with = "crate::deserialize_case_closure_observation")] + #[schemars(required)] + expected_closure_operation_key: Option, + operation_key: String, + action: crate::TicketCaseAction, + explanation: String, + #[serde(default)] + references: Vec, } #[derive(Debug, Deserialize, schemars::JsonSchema)] @@ -879,12 +818,7 @@ struct TicketQueueTool { } #[derive(Clone)] -struct TicketWorkflowStateTool { - backend: TicketToolBackend, -} - -#[derive(Clone)] -struct TicketCloseTool { +struct TicketCaseUpdateTool { backend: TicketToolBackend, } @@ -933,7 +867,7 @@ impl Tool for TicketCreateTool { let params: TicketCreateParams = parse_input("TicketCreate", input_json)?; if params .state - .is_some_and(|state| !matches!(state.into_state(), TicketWorkflowState::Planning)) + .is_some_and(|state| !matches!(state.into_state(), TicketState::Open)) { return Err(backend_error( "TicketCreate", @@ -941,7 +875,7 @@ impl Tool for TicketCreateTool { from: "creation".to_owned(), to: params .state - .expect("checked non-planning state") + .expect("checked non-open state") .into_state() .as_str() .to_owned(), @@ -956,7 +890,7 @@ impl Tool for TicketCreateTool { input.assignee = params.assignee; input.readiness = params.readiness; input.risk_flags = params.risk_flags; - input.workflow_state = params.state.map(TicketWorkflowStateParam::into_state); + input.workflow_state = params.state.map(TicketStateParam::into_state); input.queued_by = None; input.queued_at = params.queued_at; input.targets = params.targets; @@ -969,7 +903,7 @@ impl Tool for TicketCreateTool { format!("Created ticket {}", created.id), json!(TicketRefOutput { id: created.id, - state: "planning".to_string(), + state: "open".to_string(), }), )) } @@ -1183,7 +1117,7 @@ impl Tool for TicketMarkReadyTool { ) .map_err(|error| backend_error("TicketMarkReady", error))?; Ok(json_output( - format!("Marked ticket {} state ready", params.ticket), + format!("Marked ticket {} routing readiness ready", params.ticket), json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), @@ -1215,7 +1149,10 @@ impl Tool for TicketIntakeReadyTool { ) .map_err(|error| backend_error("TicketIntakeReady", error))?; Ok(json_output( - format!("Marked ticket {} state ready after intake", params.ticket), + format!( + "Marked ticket {} routing readiness ready after intake", + params.ticket + ), json!({ "ticket": ticket.meta.id, "state": ticket.meta.workflow_state.as_str(), @@ -1263,68 +1200,42 @@ impl Tool for TicketQueueTool { } #[async_trait] -impl Tool for TicketWorkflowStateTool { +impl Tool for TicketCaseUpdateTool { async fn execute( &self, input_json: &str, _ctx: agen::tool::ToolExecutionContext, ) -> Result { - let params: TicketWorkflowStateParams = parse_input("TicketWorkflowState", input_json)?; - let from = params.from.into_state(); - let to = params.to.into_state(); - if from == to { - return Err(ToolError::InvalidArgument( - "state transition must change state".to_string(), - )); - } - let mut change = - TicketStateChange::new(from.as_str(), to.as_str(), params.reason, params.body); - change.author = None; - self.backend - .set_workflow_state(TicketIdOrSlug::Query(params.ticket.clone()), change) - .map_err(|error| backend_error("TicketWorkflowState", error))?; - let ticket_ref = - model_ticket_reference(&self.backend, ¶ms.ticket, "TicketWorkflowState")?; + let params: TicketCaseUpdateParams = parse_input("TicketCaseUpdate", input_json)?; + let record = self + .backend + .update_case( + ¶ms.ticket, + crate::TicketCaseUpdate { + expected_content_digest: params.expected_content_digest, + expected_state: params.expected_state, + expected_closure_operation_key: params.expected_closure_operation_key, + operation_key: params.operation_key, + action: params.action, + explanation: params.explanation, + references: params.references, + author: None, + }, + ) + .map_err(|error| backend_error("TicketCaseUpdate", error))?; + let reference = model_ticket_reference(&self.backend, ¶ms.ticket, "TicketCaseUpdate")?; Ok(json_output( format!( - "Transitioned ticket {} state {} -> {}", - ticket_ref, - from.as_str(), - to.as_str() + "Updated Ticket {} case to {}", + reference, record.meta.workflow_state ), json!({ - "ticket": ticket_ref, - "from": from.as_str(), - "to": to.as_str(), - "state": to.as_str(), - "ok": true + "ticket": reference, "state": record.meta.workflow_state, "closure_operation_key": record.closure_operation_key, "current_closure": record.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "author": j.author, "explanation": truncate_text(&j.explanation, DEFAULT_BODY_MAX_BYTES)})), "ok": true }), )) } } -#[async_trait] -impl Tool for TicketCloseTool { - async fn execute( - &self, - input_json: &str, - _ctx: agen::tool::ToolExecutionContext, - ) -> Result { - let params: TicketCloseParams = parse_input("TicketClose", input_json)?; - self.backend - .close( - TicketIdOrSlug::Query(params.ticket.clone()), - MarkdownText::new(params.resolution), - ) - .map_err(|error| backend_error("TicketClose", error))?; - let ticket_ref = model_ticket_reference(&self.backend, ¶ms.ticket, "TicketClose")?; - Ok(json_output( - format!("Closed ticket {ticket_ref}"), - json!({ "ticket": ticket_ref, "state": "closed", "ok": true }), - )) - } -} - #[async_trait] impl Tool for TicketRelationRecordTool { async fn execute( @@ -1740,6 +1651,11 @@ fn ticket_json( "queued_at": ticket.meta.queued_at, "targets": ticket.meta.targets, }, + "content_digest": crate::ticket_content_digest(ticket), + "closure_operation_key": ticket.closure_operation_key, + "current_closure": ticket.current_closure.as_ref().map(|j| json!({"operation_key": j.operation_key, "reason": j.reason, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})), + "case_history": { "count": ticket.case_history.len(), "items": ticket.case_history.iter().rev().take(event_limit).map(|j| json!({"operation_key": j.operation_key, "previous_closure_operation_key": j.previous_closure_operation_key, "action": j.action, "explanation": truncate_text(&j.explanation, body_max_bytes), "author": j.author, "references": j.references, "at": j.at})).collect::>() }, + "legacy_workflow_snapshot": ticket.legacy_workflow_snapshot, "body": truncate_text(ticket.document.body.as_str(), body_max_bytes), "events": { "count": event_count, @@ -1850,10 +1766,7 @@ fn input_schema(name: &str) -> Value { "TicketMarkReady" => serde_json::to_value(schemars::schema_for!(TicketMarkReadyParams)), "TicketIntakeReady" => serde_json::to_value(schemars::schema_for!(TicketIntakeReadyParams)), "TicketQueue" => serde_json::to_value(schemars::schema_for!(TicketQueueParams)), - "TicketWorkflowState" => { - serde_json::to_value(schemars::schema_for!(TicketWorkflowStateParams)) - } - "TicketClose" => serde_json::to_value(schemars::schema_for!(TicketCloseParams)), + "TicketCaseUpdate" => serde_json::to_value(schemars::schema_for!(TicketCaseUpdateParams)), "TicketDependencyCheck" => { serde_json::to_value(schemars::schema_for!(TicketDependencyCheckParams)) } @@ -1899,8 +1812,7 @@ impl_from_backend!(TicketImplementationReportTool); impl_from_backend!(TicketMarkReadyTool); impl_from_backend!(TicketIntakeReadyTool); impl_from_backend!(TicketQueueTool); -impl_from_backend!(TicketWorkflowStateTool); -impl_from_backend!(TicketCloseTool); +impl_from_backend!(TicketCaseUpdateTool); impl_from_backend!(TicketRelationRecordTool); impl_from_backend!(TicketRelationRemoveTool); impl_from_backend!(TicketRelationQueryTool); @@ -1927,8 +1839,7 @@ pub fn ticket_tools(backend: impl Into) -> Vec("TicketMarkReady", backend.clone()), tool_definition::("TicketIntakeReady", backend.clone()), tool_definition::("TicketQueue", backend.clone()), - tool_definition::("TicketWorkflowState", backend.clone()), - tool_definition::("TicketClose", backend.clone()), + tool_definition::("TicketCaseUpdate", backend.clone()), tool_definition::("TicketDependencyCheck", backend.clone()), tool_definition::("TicketDoctor", backend.clone()), tool_definition::("TicketRelationRecord", backend.clone()), @@ -2015,7 +1926,7 @@ mod tests { fn post_mutation_reference_failures_are_outcome_unknown() { let temp = TempDir::new().unwrap(); let backend = TicketToolBackend::new(backend(&temp)); - let error = model_ticket_reference(&backend, "missing", "TicketClose").unwrap_err(); + let error = model_ticket_reference(&backend, "missing", "TicketCaseUpdate").unwrap_err(); assert!(matches!( error, ToolError::Internal(message) @@ -2048,8 +1959,7 @@ mod tests { "TicketMarkReady", "TicketIntakeReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord" @@ -2069,24 +1979,6 @@ mod tests { ); } - #[test] - fn state_tool_description_explains_phase_independence() { - let temp = TempDir::new().unwrap(); - let definition = ticket_tools(backend(&temp)) - .into_iter() - .find(|definition| definition().0.name == "TicketWorkflowState") - .expect("state tool exists"); - let (meta, _) = definition(); - assert!( - meta.description - .contains("do not impose an implementation phase path") - ); - assert!( - meta.description - .contains("does not revive released work identities") - ); - } - #[test] fn tool_descriptions_include_configured_ticket_record_language_guidance() { let temp = TempDir::new().unwrap(); @@ -2151,10 +2043,7 @@ mod tests { assert!(!created_text.contains("attention_required")); let listed = list - .execute( - &json!({ "state": "planning" }).to_string(), - Default::default(), - ) + .execute(&json!({ "state": "open" }).to_string(), Default::default()) .await .unwrap(); assert!(listed.summary.contains("Listed 1 ticket")); @@ -2276,7 +2165,7 @@ mod tests { let list = tool_by_name(backend.clone(), "QueryTicket"); for index in 0..(DEFAULT_LIST_LIMIT + 3) { let mut ticket = NewTicket::new(format!("Closed Ticket {index:03}")); - ticket.workflow_state = Some(TicketWorkflowState::Closed); + ticket.workflow_state = Some(TicketState::Closed); backend.create(ticket).unwrap(); } for index in 0..3 { @@ -2334,22 +2223,22 @@ mod tests { let list = tool_by_name(backend.clone(), "QueryTicket"); let planning = backend.create(NewTicket::new("Planning Ticket")).unwrap(); let mut ready_input = NewTicket::new("Ready Ticket"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); + ready_input.workflow_state = Some(TicketState::Open); let ready = backend.create(ready_input).unwrap(); let mut closed_input = NewTicket::new("Closed Ticket"); - closed_input.workflow_state = Some(TicketWorkflowState::Closed); + closed_input.workflow_state = Some(TicketState::Closed); let closed = backend.create(closed_input).unwrap(); let listed = list .execute( - &json!({ "states": ["planning", "closed"] }).to_string(), + &json!({ "states": ["open", "closed"] }).to_string(), Default::default(), ) .await .unwrap(); let listed_json: Value = serde_json::from_str(&listed.content.unwrap()).unwrap(); - assert_eq!(listed_json["state_filter"], "planning,closed"); - assert_eq!(listed_json["count"].as_u64(), Some(2)); + assert_eq!(listed_json["state_filter"], "open,closed"); + assert_eq!(listed_json["count"].as_u64(), Some(3)); let listed_ids = listed_json["tickets"] .as_array() .unwrap() @@ -2358,11 +2247,11 @@ mod tests { .collect::>(); assert!(listed_ids.contains(&planning.id.as_str())); assert!(listed_ids.contains(&closed.id.as_str())); - assert!(!listed_ids.contains(&ready.id.as_str())); + assert!(listed_ids.contains(&ready.id.as_str())); let mixed = list .execute( - &json!({ "state": "active", "states": ["planning"] }).to_string(), + &json!({ "state": "active", "states": ["open"] }).to_string(), Default::default(), ) .await; @@ -2374,62 +2263,6 @@ mod tests { assert!(empty.is_err()); } - #[tokio::test] - async fn ticket_list_tool_omits_body_thread_artifact_and_resolution_content() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let list = tool_by_name(backend.clone(), "QueryTicket"); - let close = tool_by_name(backend.clone(), "TicketClose"); - let body_secret = "ITEM_BODY_SECRET_DO_NOT_LIST"; - let thread_secret = "THREAD_SECRET_DO_NOT_LIST"; - let resolution_secret = "RESOLUTION_SECRET_DO_NOT_LIST"; - let mut ticket = NewTicket::new("Leak Probe"); - ticket.body = MarkdownText::new(format!("Item body {body_secret}")); - ticket.workflow_state = Some(TicketWorkflowState::Done); - let created = backend.create(ticket).unwrap(); - backend - .add_event( - TicketIdOrSlug::Id(created.id.clone()), - NewTicketEvent::new(TicketEventKind::Comment, format!("Thread {thread_secret}")), - ) - .unwrap(); - close - .execute( - &json!({ - "ticket": created.id, - "resolution": format!("Resolution {resolution_secret}") - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - - let listed = list - .execute( - &json!({ "state": "closed" }).to_string(), - Default::default(), - ) - .await - .unwrap(); - let listed_content = listed.content.unwrap(); - for secret in [body_secret, thread_secret, resolution_secret] { - assert!(!listed_content.contains(secret)); - } - let listed_json: Value = serde_json::from_str(&listed_content).unwrap(); - let ticket = listed_json["tickets"][0].as_object().unwrap(); - for forbidden_key in [ - "body", - "document", - "events", - "thread", - "artifacts", - "resolution", - ] { - assert!(!ticket.contains_key(forbidden_key)); - } - } - #[tokio::test] async fn ticket_edit_item_tool_supports_exact_body_replacement() { let temp = TempDir::new().unwrap(); @@ -2582,234 +2415,6 @@ mod tests { assert_eq!(shown_after_remove_json["relations"]["incoming"], json!([])); } - #[tokio::test] - async fn ticket_tools_report_state_and_close_are_doctor_clean() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let created = backend.create(NewTicket::new("Flow Tool")).unwrap(); - let report = tool_by_name(backend.clone(), "TicketImplementationReport"); - let close = tool_by_name(backend.clone(), "TicketClose"); - let doctor = tool_by_name(backend.clone(), "TicketDoctor"); - - report - .execute( - &json!({ - "ticket": created.id.clone(), - "body": "Implemented." - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - close - .execute( - &json!({ "ticket": created.id, "resolution": "Done via TicketClose.\n" }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - - let report = doctor - .execute(&json!({}).to_string(), Default::default()) - .await - .unwrap(); - assert!(report.summary.contains("0 error(s)")); - let closed = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert!(closed.resolution.is_some()); - assert!( - closed - .events - .iter() - .any(|event| event.kind == TicketEventKind::ImplementationReport) - ); - assert!( - closed - .events - .iter() - .any(|event| event.kind == TicketEventKind::Close) - ); - } - - #[tokio::test] - async fn queue_workflow_and_close_project_internal_inputs_to_ticket_keys() { - let temp = TempDir::new().unwrap(); - let inner = sqlite_backend(&temp); - let mut dependency_input = NewTicket::new("Dependency"); - dependency_input.targets = vec![write_target("main")]; - let dependency = inner.create(dependency_input).unwrap(); - let mut target_input = NewTicket::new("Target"); - target_input.targets = vec![write_target("main")]; - let target = inner.create(target_input).unwrap(); - inner - .add_ticket_relation( - TicketIdOrSlug::Id(target.id.clone()), - NewTicketRelation { - kind: TicketRelationKind::DependsOn, - target: dependency.id.clone(), - note: None, - author: None, - }, - ) - .unwrap(); - for id in [&dependency.id, &target.id] { - inner - .mark_ready( - TicketIdOrSlug::Id(id.clone()), - TicketMarkReady { - operation_key: format!("ready-{id}"), - reason: None, - author: None, - intake_summary: None, - }, - ) - .unwrap(); - } - let target_key = target.resource_key.clone().unwrap(); - let dependency_key = dependency.resource_key.clone().unwrap(); - let backend = inner; - let queue = tool_by_name(TicketToolBackend::new(backend.clone()), "TicketQueue"); - let workflow = tool_by_name( - TicketToolBackend::new(backend.clone()), - "TicketWorkflowState", - ); - let close = tool_by_name(TicketToolBackend::new(backend), "TicketClose"); - - let queued = queue - .execute( - &json!({"ticket": target.id.clone()}).to_string(), - Default::default(), - ) - .await - .unwrap(); - assert!(queued.summary.contains("1 ticket(s)")); - let queued_content = queued.content.unwrap(); - assert!(queued_content.contains(&target_key)); - assert!(!queued_content.contains(&dependency_key)); - assert!(!queued_content.contains(&target.id)); - assert!(!queued_content.contains(&dependency.id)); - - for (from, to) in [("queued", "inprogress"), ("inprogress", "done")] { - let transitioned = workflow - .execute( - &json!({ - "ticket": target.id.clone(), - "from": from, - "to": to, - "reason": "test_transition", - "body": "transitioned", - "author": "tester" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - assert!(transitioned.summary.contains(&target_key)); - assert!(!transitioned.summary.contains(&target.id)); - let content = transitioned.content.unwrap(); - assert!(content.contains(&target_key)); - assert!(!content.contains(&target.id)); - } - - let closed = close - .execute( - &json!({"ticket": target.id.clone(), "resolution": "Done"}).to_string(), - Default::default(), - ) - .await - .unwrap(); - assert!(closed.summary.contains(&target_key)); - assert!(!closed.summary.contains(&target.id)); - let content = closed.content.unwrap(); - assert!(content.contains(&target_key)); - assert!(!content.contains(&target.id)); - } - - #[tokio::test] - async fn ticket_workflow_tools_mark_ready_and_transition_state() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let mut input = NewTicket::new("Workflow Tool"); - input.targets = vec![write_target("main")]; - let created = backend.create(input).unwrap(); - let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady"); - let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); - - intake_ready - .execute( - &json!({ - "ticket": created.id.clone(), - "reason": "requirements accepted" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - backend - .queue_ready(TicketIdOrSlug::Id(created.id.clone()), "panel") - .unwrap(); - workflow - .execute( - &json!({ - "ticket": created.id.clone(), - "from": "queued", - "to": "inprogress", - "reason": "orchestrator_started", - "body": "Orchestrator started implementation.\n", - "author": "orchestrator" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - workflow - .execute( - &json!({ - "ticket": created.id.clone(), - "from": "inprogress", - "to": "done", - "reason": "implementation_complete", - "body": "Implementation finished and is ready for close.\n", - "author": "orchestrator" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - - let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Done); - assert!(record.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("planning") - && event.to.as_deref() == Some("ready") - && event.attributes.contains_key("request_fingerprint") - })); - let transitions = record - .events - .iter() - .filter(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - }) - .map(|event| (event.from.as_deref(), event.to.as_deref())) - .collect::>(); - assert_eq!( - transitions, - vec![ - (Some("planning"), Some("ready")), - (Some("ready"), Some("queued")), - (Some("queued"), Some("inprogress")), - (Some("inprogress"), Some("done")) - ] - ); - } - #[tokio::test] async fn ticket_intake_ready_records_summary_with_validated_target() { let temp = TempDir::new().unwrap(); @@ -2830,7 +2435,7 @@ mod tests { .await .unwrap(); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(record.meta.workflow_state, TicketState::Open); assert_eq!( record.meta.targets[0].ref_selector.as_deref(), Some("develop") @@ -2846,132 +2451,11 @@ mod tests { } #[tokio::test] - async fn ticket_workflow_tool_allows_return_to_planning_from_ready_and_queued() { + async fn ticket_mark_ready_tool_rejects_closed_ticket() { let temp = TempDir::new().unwrap(); let backend = backend(&temp); - let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); - - let mut ready_input = NewTicket::new("Ready Needs Planning"); - ready_input.workflow_state = Some(TicketWorkflowState::Ready); - let ready = backend.create(ready_input).unwrap(); - workflow - .execute( - &json!({ - "ticket": ready.id, - "from": "ready", - "to": "planning", - "reason": "missing_acceptance_decision", - "body": "Missing decision: clarify acceptance criteria before queueing.\n", - "author": "orchestrator" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - let ready_record = backend.show(TicketIdOrSlug::Id(ready.id)).unwrap(); - assert_eq!( - ready_record.meta.workflow_state, - TicketWorkflowState::Planning - ); - assert!(ready_record.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("ready") - && event.to.as_deref() == Some("planning") - && event.reason.as_deref() == Some("missing_acceptance_decision") - })); - - let mut queued_input = NewTicket::new("Queued Needs Planning"); - queued_input.workflow_state = Some(TicketWorkflowState::Queued); - let queued = backend.create(queued_input).unwrap(); - workflow - .execute( - &json!({ - "ticket": queued.id, - "from": "queued", - "to": "planning", - "reason": "missing_authority_decision", - "body": "Missing decision: define authority boundary before implementation side effects.\n", - "author": "orchestrator" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap(); - let queued_record = backend.show(TicketIdOrSlug::Id(queued.id)).unwrap(); - assert_eq!( - queued_record.meta.workflow_state, - TicketWorkflowState::Planning - ); - assert!(queued_record.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.from.as_deref() == Some("queued") - && event.to.as_deref() == Some("planning") - && event.reason.as_deref() == Some("missing_authority_decision") - })); - } - - #[tokio::test] - async fn ticket_workflow_tool_rejects_stale_transition_without_state_move() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let created = backend - .create(NewTicket::new("Stale Workflow Tool")) - .unwrap(); - let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); - - let error = workflow - .execute( - &json!({ - "ticket": created.id.clone(), - "from": "queued", - "to": "inprogress", - "reason": "orchestrator_started", - "body": "Should not apply.\n" - }) - .to_string(), - Default::default(), - ) - .await - .unwrap_err(); - - assert!(matches!(error, ToolError::ExecutionFailed(_))); - let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Planning); - assert!(!record.events.iter().any(|event| { - event.kind == TicketEventKind::StateChanged - && event.state_field.as_deref() == Some("state") - })); - } - - #[tokio::test] - async fn ticket_workflow_tool_allows_direct_state_changes_without_phase_path() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let workflow = tool_by_name(backend.clone(), "TicketWorkflowState"); - for (from, to) in [ - (TicketWorkflowState::Ready, TicketWorkflowState::InProgress), - (TicketWorkflowState::Done, TicketWorkflowState::Planning), - (TicketWorkflowState::Queued, TicketWorkflowState::Done), - ] { - let mut input = NewTicket::new(format!("{from} to {to}")); - input.workflow_state = Some(from); - let ticket = backend.create(input).unwrap(); - workflow.execute(&json!({"ticket": ticket.id, "from": from.as_str(), "to": to.as_str(), "reason": "explicit_update", "body": "State metadata updated."}).to_string(), Default::default()).await.unwrap(); - assert_eq!( - backend.show(ticket.id.into()).unwrap().meta.workflow_state, - to - ); - } - } - - #[tokio::test] - async fn ticket_mark_ready_tool_rejects_non_planning_ticket() { - let temp = TempDir::new().unwrap(); - let backend = backend(&temp); - let mut input = NewTicket::new("Already Ready"); - input.workflow_state = Some(TicketWorkflowState::Ready); + let mut input = NewTicket::new("Already Closed"); + input.workflow_state = Some(TicketState::Closed); let created = backend.create(input).unwrap(); let intake_ready = tool_by_name(backend.clone(), "TicketMarkReady"); @@ -2979,7 +2463,7 @@ mod tests { .execute( &json!({ "ticket": created.id.clone(), - "intake_summary": "Should not rewrite ready ticket." + "intake_summary": "Should not rewrite closed case." }) .to_string(), Default::default(), @@ -2989,7 +2473,7 @@ mod tests { assert!(error.to_string().contains("stale ticket workflow state")); let record = backend.show(TicketIdOrSlug::Id(created.id)).unwrap(); - assert_eq!(record.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(record.meta.workflow_state, TicketState::Closed); assert!(!record.events.iter().any(|event| { event.kind == TicketEventKind::StateChanged && event.state_field.as_deref() == Some("state") @@ -3042,7 +2526,7 @@ mod tests { assert_eq!(found_json["records"][0]["related_ticket"], second.id); let current = backend.show(TicketIdOrSlug::Id(first.id)).unwrap(); - assert_eq!(current.meta.workflow_state, TicketWorkflowState::Planning); + assert_eq!(current.meta.workflow_state, TicketState::Open); } #[tokio::test] @@ -3193,4 +2677,147 @@ mod tests { )); let _ = create; } + #[test] + fn case_tool_schema_exposes_common_cas_and_no_legacy_unguarded_mutation() { + let temp = TempDir::new().unwrap(); + let definition = ticket_tools(backend(&temp)) + .into_iter() + .find(|d| d().0.name == "TicketCaseUpdate") + .unwrap(); + let (meta, _) = definition(); + let schema = &meta.input_schema; + for field in [ + "ticket", + "expected_content_digest", + "expected_state", + "expected_closure_operation_key", + "operation_key", + "action", + "explanation", + ] { + assert!( + schema["required"] + .as_array() + .unwrap() + .iter() + .any(|name| name == field), + "missing {field}: {schema}" + ); + } + assert!( + schema["properties"] + .get("expected_closure_operation_key") + .is_some() + ); + assert!(!TICKET_TOOL_NAMES.contains(&"TicketWorkflowState")); + assert!(!TICKET_TOOL_NAMES.contains(&"TicketClose")); + assert!(meta.description.contains("1024 UTF-8 bytes")); + } + + #[tokio::test] + async fn case_tool_closes_replays_corrects_and_reopens_with_bounded_show_history() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let created = backend.create(NewTicket::new("Case via tool")).unwrap(); + let observed = backend.show(created.id.clone().into()).unwrap(); + let update = tool_by_name(backend.clone(), "TicketCaseUpdate"); + let input = json!({"ticket": created.id, "expected_content_digest": crate::ticket_content_digest(&observed), "expected_state": "open", "expected_closure_operation_key": null, + "operation_key": "tool-close", "action": {"action":"close", "reason":"not_planned"}, "explanation": "Not pursuing this case.\n\nThis is not a failure milestone.", "references": [{"kind":"result", "target":"doc:decision"}]}); + update + .execute(&input.to_string(), Default::default()) + .await + .unwrap(); + update + .execute(&input.to_string(), Default::default()) + .await + .unwrap(); + let closed = backend.show(created.id.clone().into()).unwrap(); + assert_eq!(closed.case_history.len(), 1); + assert_eq!( + closed.current_closure.as_ref().unwrap().reason, + crate::TicketClosureReason::NotPlanned + ); + let mut correction = input.clone(); + correction["expected_state"] = json!("closed"); + correction["expected_closure_operation_key"] = json!("tool-close"); + correction["operation_key"] = json!("tool-correct"); + correction["action"] = json!({"action":"correct_closure", "reason":"duplicate"}); + correction["explanation"] = json!("A long judgment ".repeat(1000)); + update + .execute(&correction.to_string(), Default::default()) + .await + .unwrap(); + let show = tool_by_name(backend.clone(), "ShowTicket"); + let output = show + .execute( + &json!({"id":created.id, "event_limit":1, "body_max_bytes":128}).to_string(), + Default::default(), + ) + .await + .unwrap(); + let value: Value = serde_json::from_str(&output.content.unwrap()).unwrap(); + assert_eq!(value["closure_operation_key"], "tool-correct"); + assert_eq!(value["case_history"]["count"], 2); + assert_eq!(value["case_history"]["items"].as_array().unwrap().len(), 1); + assert!( + value["current_closure"]["explanation"] + .as_str() + .unwrap() + .len() + < 256 + ); + assert!(value["content_digest"].as_str().unwrap().len() == 64); + let mut reopen = correction; + reopen["expected_closure_operation_key"] = json!("tool-correct"); + reopen["operation_key"] = json!("tool-reopen"); + reopen["action"] = json!({"action":"reopen"}); + reopen["explanation"] = json!("Reconsider"); + update + .execute(&reopen.to_string(), Default::default()) + .await + .unwrap(); + let reopened = backend.show(created.id.clone().into()).unwrap(); + assert_eq!(reopened.meta.workflow_state, TicketState::Open); + assert_eq!(reopened.case_history.len(), 3); + assert_eq!( + reopened.closure_operation_key.as_deref(), + Some("tool-correct") + ); + assert!( + update + .execute( + &reopen.to_string().replace("tool-correct", "wrong-closure"), + Default::default() + ) + .await + .is_err() + ); + } + + #[tokio::test] + async fn case_tool_rejects_legacy_states_and_unrecognized_input_fields() { + let temp = TempDir::new().unwrap(); + let backend = backend(&temp); + let created = backend.create(NewTicket::new("Validation")).unwrap(); + let observed = backend.show(created.id.clone().into()).unwrap(); + let update = tool_by_name(backend.clone(), "TicketCaseUpdate"); + let input = json!({"ticket":created.id, "expected_content_digest":crate::ticket_content_digest(&observed), "expected_state":"open", "operation_key":"key", "action":{"action":"close", "reason":"unknown"}, "explanation":"Reason"}); + let mut legacy = input.clone(); + legacy["expected_state"] = json!("done"); + assert!( + update + .execute(&legacy.to_string(), Default::default()) + .await + .is_err() + ); + let mut unknown = input; + unknown["from"] = json!("open"); + assert!( + update + .execute(&unknown.to_string(), Default::default()) + .await + .is_err() + ); + assert_eq!(backend.show(created.id.into()).unwrap(), observed); + } } diff --git a/crates/ticket/tests/case.rs b/crates/ticket/tests/case.rs new file mode 100644 index 000000000..c4dd8c321 --- /dev/null +++ b/crates/ticket/tests/case.rs @@ -0,0 +1,800 @@ +//! Public Ticket case authority contracts over isolated SQLite stores. +use rusqlite::{Connection, params}; +use std::collections::BTreeMap; +use std::sync::{Arc, Barrier}; +use tempfile::TempDir; +use ticket::*; + +fn backend(temp: &TempDir) -> SqliteTicketBackend { + SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap() +} +fn request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate { + TicketCaseUpdate { + expected_content_digest: ticket_content_digest(ticket), + expected_state: ticket.meta.workflow_state, + expected_closure_operation_key: ticket.closure_operation_key.clone(), + operation_key: key.into(), + action, + explanation: "A reasoned judgment.\n\nNot an execution milestone.".into(), + references: vec![TicketReference { + kind: "result".into(), + target: "document:example".into(), + }], + author: Some("human:alice".into()), + } +} +fn close(reason: TicketClosureReason) -> TicketCaseAction { + TicketCaseAction::Close { reason } +} +fn create(backend: &SqliteTicketBackend) -> Ticket { + let mut input = NewTicket::new("Independent case"); + input.assignee = Some("historical-assignee".into()); + backend.create_with_snapshot(input).unwrap().1 +} + +#[test] +fn close_reopen_and_correction_preserve_reason_actor_references_and_identity() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::NotPlanned)), + ) + .unwrap(); + assert!(closed.meta.targets.is_empty()); + assert_eq!(closed.meta.workflow_state, TicketState::Closed); + let first_judgment = closed.current_closure.clone().unwrap(); + assert_eq!(first_judgment.reason, TicketClosureReason::NotPlanned); + assert_eq!(first_judgment.author, "human:alice"); + assert_eq!( + first_judgment.references, + request(&initial, "close", close(TicketClosureReason::NotPlanned)).references + ); + let corrected = store + .update_case( + &initial.meta.id, + request( + &closed, + "correct", + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Duplicate, + }, + ), + ) + .unwrap(); + assert_eq!( + corrected.current_closure.as_ref().unwrap().reason, + TicketClosureReason::Duplicate + ); + assert_eq!(corrected.case_history[0], closed.case_history[0]); + assert_eq!( + corrected.case_history[1] + .previous_closure_operation_key + .as_deref(), + Some("close") + ); + let reopened = store + .update_case( + &initial.meta.id, + request(&corrected, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + assert_eq!(reopened.meta.workflow_state, TicketState::Open); + assert!(reopened.current_closure.is_none()); + assert!(reopened.resolution.is_none()); + assert_eq!(reopened.closure_operation_key.as_deref(), Some("correct")); + assert_eq!(reopened.case_history.len(), 3); + assert_eq!(reopened.meta.assignee, initial.meta.assignee); + assert_eq!(reopened.meta.resource_key, initial.meta.resource_key); + assert_eq!( + &reopened.events[..initial.events.len()], + initial.events.as_slice() + ); + assert_eq!( + store.show(initial.meta.id.clone().into()).unwrap(), + reopened + ); +} + +#[test] +fn closure_reasons_round_trip_without_repository_or_merge_request_prerequisites() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + for reason in [ + TicketClosureReason::Completed, + TicketClosureReason::NotPlanned, + TicketClosureReason::Duplicate, + TicketClosureReason::Unknown, + TicketClosureReason::Other("Custom decision".into()), + ] { + let initial = create(&store); + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(reason.clone())), + ) + .unwrap(); + assert_eq!(closed.current_closure.unwrap().reason, reason); + } +} + +#[test] +fn case_cas_rejects_stale_content_state_and_closure_key_without_writes() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let base = request(&initial, "close", close(TicketClosureReason::Completed)); + let mut stale = base.clone(); + stale.expected_content_digest = "stale".into(); + assert!(matches!( + store.update_case(&initial.meta.id, stale), + Err(TicketError::StaleContent { .. }) + )); + let mut stale = base.clone(); + stale.expected_state = TicketState::Closed; + assert!(matches!( + store.update_case(&initial.meta.id, stale), + Err(TicketError::StaleWorkflowState { .. }) + )); + let mut stale = base; + stale.expected_closure_operation_key = Some("unobserved".into()); + assert!(matches!( + store.update_case(&initial.meta.id, stale), + Err(TicketError::StaleClosureOperationKey { .. }) + )); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); +} + +#[test] +fn closure_keys_protect_closed_correction_and_open_closed_open_aba() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let stale_open = request( + &initial, + "late-close", + close(TicketClosureReason::Completed), + ); + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::Unknown)), + ) + .unwrap(); + let stale_correction = request( + &closed, + "late-correct", + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Completed, + }, + ); + let corrected = store + .update_case( + &initial.meta.id, + request( + &closed, + "correct", + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::NotPlanned, + }, + ), + ) + .unwrap(); + assert!(matches!( + store.update_case(&initial.meta.id, stale_correction), + Err(TicketError::StaleClosureOperationKey { .. }) + )); + let reopened = store + .update_case( + &initial.meta.id, + request(&corrected, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + assert_eq!( + ticket_content_digest(&reopened), + ticket_content_digest(&initial) + ); + assert!(matches!( + store.update_case(&initial.meta.id, stale_open), + Err(TicketError::StaleClosureOperationKey { .. }) + )); + assert_eq!( + store.show(initial.meta.id.clone().into()).unwrap(), + reopened + ); +} + +#[test] +fn exact_replay_after_edit_reopen_and_backend_restart_does_not_reapply_a_close() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + let closed = store + .update_case(&initial.meta.id, operation.clone()) + .unwrap(); + let reopened = store + .update_case( + &initial.meta.id, + request(&closed, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + let edited = store + .edit_item( + initial.meta.id.clone().into(), + TicketItemEdit { + title: Some("Changed requirements".into()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(edited.case_history, reopened.case_history); + let restored = + SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "workspace").unwrap(); + assert_eq!( + restored.update_case(&initial.meta.id, operation).unwrap(), + edited + ); +} + +#[test] +fn replay_fingerprint_binds_every_request_field_and_authenticated_source_identity() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + let closed = store + .update_case(&initial.meta.id, operation.clone()) + .unwrap(); + let mut changed = Vec::new(); + let mut x = operation.clone(); + x.expected_content_digest.push('x'); + changed.push(x); + let mut x = operation.clone(); + x.expected_state = TicketState::Closed; + changed.push(x); + let mut x = operation.clone(); + x.expected_closure_operation_key = Some("close".into()); + changed.push(x); + let mut x = operation.clone(); + x.action = close(TicketClosureReason::NotPlanned); + changed.push(x); + let mut x = operation.clone(); + x.action = TicketCaseAction::Reopen; + changed.push(x); + let mut x = operation.clone(); + x.explanation.push('x'); + changed.push(x); + let mut x = operation.clone(); + x.references.clear(); + changed.push(x); + let mut x = operation.clone(); + x.author = None; + changed.push(x); + for input in changed { + assert!(matches!( + store.update_case(&initial.meta.id, input), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + } + let other_source = store.clone().with_event_attributes(BTreeMap::from([( + "source_runtime_id".into(), + "other-runtime".into(), + )])); + assert!(matches!( + other_source.update_case(&initial.meta.id, operation), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed); +} + +#[test] +fn invalid_action_does_not_close_correct_or_reopen_the_wrong_case() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + for action in [ + TicketCaseAction::Reopen, + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Completed, + }, + ] { + assert!( + store + .update_case(&initial.meta.id, request(&initial, "invalid", action)) + .is_err() + ); + } + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::Unknown)), + ) + .unwrap(); + assert!( + store + .update_case( + &initial.meta.id, + request( + &closed, + "double-close", + close(TicketClosureReason::Completed) + ) + ) + .is_err() + ); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed); +} + +#[test] +fn custom_reason_limits_are_utf8_bytes_and_validation_keeps_long_markdown_explanations() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + for reason in [ + "".into(), + " \n".into(), + "bad\0reason".into(), + "あ".repeat(342), + ] { + assert!( + store + .update_case( + &initial.meta.id, + request(&initial, "bad", close(TicketClosureReason::Other(reason))) + ) + .is_err() + ); + } + let mut valid = request( + &initial, + "close", + close(TicketClosureReason::Other("x".repeat(1024))), + ); + valid.explanation = format!( + "## Decision\n{}\n", + "long".repeat(8000) + ); + let closed = store.update_case(&initial.meta.id, valid.clone()).unwrap(); + assert_eq!( + closed.current_closure.as_ref().unwrap().explanation, + valid.explanation + ); + assert_eq!(store.update_case(&initial.meta.id, valid).unwrap(), closed); +} + +#[test] +fn blank_explanation_actor_reference_or_key_rejects_without_recording_receipts() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let base = request(&initial, "key", close(TicketClosureReason::Unknown)); + let mut cases = Vec::new(); + let mut x = base.clone(); + x.explanation = " ".into(); + cases.push(x); + let mut x = base.clone(); + x.explanation = "bad\0text".into(); + cases.push(x); + let mut x = base.clone(); + x.author = Some(" ".into()); + cases.push(x); + let mut x = base.clone(); + x.references[0].target.clear(); + cases.push(x); + let mut x = base.clone(); + x.references[0].kind.clear(); + cases.push(x); + let mut x = base; + x.operation_key.clear(); + cases.push(x); + for input in cases { + assert!(store.update_case(&initial.meta.id, input).is_err()); + } + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); +} + +#[test] +fn case_history_is_immutable_in_sqlite_and_hook_failure_rolls_back_every_case_write() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let rejected = store.clone().with_mutation_hook(Arc::new(|_, _| { + Err(TicketError::Conflict("hook rejected".into())) + })); + let operation = request(&initial, "close", close(TicketClosureReason::Unknown)); + assert!( + rejected + .update_case(&initial.meta.id, operation.clone()) + .is_err() + ); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); + let closed = store.update_case(&initial.meta.id, operation).unwrap(); + let conn = Connection::open(temp.path().join("tickets.db")).unwrap(); + for sql in [ + "UPDATE typed_ticket_case_history SET judgment_json = '{}'", + "DELETE FROM typed_ticket_case_history", + ] { + assert!(conn.execute(sql, []).is_err()); + } + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed); +} + +#[test] +fn concurrent_exact_replay_creates_one_judgment_and_competing_closes_have_one_winner() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let barrier = Arc::new(Barrier::new(2)); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + let mut joins = Vec::new(); + for _ in 0..2 { + let store = store.clone(); + let barrier = barrier.clone(); + let operation = operation.clone(); + let id = initial.meta.id.clone(); + joins.push(std::thread::spawn(move || { + barrier.wait(); + store.update_case(&id, operation) + })); + } + for join in joins { + join.join().unwrap().unwrap(); + } + assert_eq!( + store + .show(initial.meta.id.clone().into()) + .unwrap() + .case_history + .len(), + 1 + ); + let initial = create(&store); + let barrier = Arc::new(Barrier::new(2)); + let mut joins = Vec::new(); + for key in ["winner-a", "winner-b"] { + let store = store.clone(); + let barrier = barrier.clone(); + let operation = request(&initial, key, close(TicketClosureReason::Unknown)); + let id = initial.meta.id.clone(); + joins.push(std::thread::spawn(move || { + barrier.wait(); + store.update_case(&id, operation) + })); + } + let outcomes: Vec<_> = joins.into_iter().map(|j| j.join().unwrap()).collect(); + assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1); + assert!( + outcomes + .iter() + .any(|result| matches!(result, Err(TicketError::StaleWorkflowState { .. }))) + ); + assert_eq!( + store + .show(initial.meta.id.clone().into()) + .unwrap() + .case_history + .len(), + 1 + ); +} + +#[test] +fn queue_and_readiness_are_routing_audit_and_never_change_case_or_reopen_closed() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let ready = store + .mark_ready( + initial.meta.id.clone().into(), + TicketMarkReady { + operation_key: "ready".into(), + reason: None, + intake_summary: None, + author: None, + }, + ) + .unwrap(); + assert_eq!(ready.meta.workflow_state, TicketState::Open); + assert_eq!(ready.meta.readiness.as_deref(), Some("ready")); + store + .queue_ready(initial.meta.id.clone().into(), "router") + .unwrap(); + let queued = store.show(initial.meta.id.clone().into()).unwrap(); + assert_eq!(queued.meta.workflow_state, TicketState::Open); + assert_eq!(queued.meta.queued_by.as_deref(), Some("router")); + let closed = store + .update_case( + &initial.meta.id, + request(&queued, "close", close(TicketClosureReason::NotPlanned)), + ) + .unwrap(); + assert!( + store + .queue_ready(initial.meta.id.clone().into(), "router") + .is_err() + ); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed); +} + +#[test] +fn workspace_boundary_rejects_other_workspace_ticket_and_shared_operation_dispatch_uses_case_api() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let other = + SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "other-workspace") + .unwrap(); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + assert!(matches!( + other.update_case(&initial.meta.id, operation.clone()), + Err(TicketError::NotFound(_)) + )); + let output = execute_ticket_backend_operation( + &store, + TicketBackendOperation::UpdateCase { + ticket: initial.meta.id.clone(), + request: operation, + }, + ) + .unwrap(); + assert!( + matches!(output, TicketBackendOperationResult::Ticket(ticket) if ticket.meta.workflow_state == TicketState::Closed) + ); +} + +#[test] +fn historical_events_and_merge_request_evidence_are_not_rewritten_by_case_judgments() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let historical = store.clone().with_event_attributes(BTreeMap::from([ + ("operation_key".into(), "old-completion".into()), + ("merge_request_id".into(), "MR-existing".into()), + ])); + let mut event = NewTicketEvent::new( + TicketEventKind::Other("ticket_completion".into()), + "Historic completed result", + ); + event.references = vec![TicketReference { + kind: "merge_request".into(), + target: "MR-existing".into(), + }]; + historical + .add_event(initial.meta.id.clone().into(), event) + .unwrap(); + let before = store.show(initial.meta.id.clone().into()).unwrap(); + let after = store + .update_case( + &initial.meta.id, + request(&before, "new-close", close(TicketClosureReason::NotPlanned)), + ) + .unwrap(); + assert_eq!( + &after.events[..before.events.len()], + before.events.as_slice() + ); + assert!( + store + .update_case( + &initial.meta.id, + request( + &before, + "old-completion", + close(TicketClosureReason::Completed) + ) + ) + .is_err() + ); +} + +#[test] +fn external_case_json_rejects_six_state_authority_and_round_trips_typed_action() { + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "in_progress", + ] { + assert!(serde_json::from_value::(serde_json::json!(state)).is_err()); + } + let encoded = serde_json::json!({"action": "correct_closure", "reason": {"other": "Custom"}}); + let action: TicketCaseAction = serde_json::from_value(encoded.clone()).unwrap(); + assert_eq!(serde_json::to_value(action).unwrap(), encoded); + assert!( + serde_json::from_value::( + serde_json::json!({"action": "reopen", "reason": "completed"}) + ) + .is_err() + ); +} + +fn assignment_source() -> BTreeMap { + BTreeMap::from([ + ("source_runtime_id".into(), "runtime-a".into()), + ("source_worker_id".into(), "worker-a".into()), + ("source_actor_role".into(), "ticket_worker".into()), + ("source_assignment_id".into(), "assignment-a".into()), + ]) +} +fn assignment_fixture(temp: &TempDir, id: &str) -> Connection { + let conn = Connection::open(temp.path().join("tickets.db")).unwrap(); + conn.execute_batch("CREATE TABLE test_worker_assignments (workspace_id TEXT, ticket_id TEXT, runtime_id TEXT, worker_id TEXT, assignment_id TEXT, status TEXT); + CREATE VIEW ticket_active_worker_assignments AS SELECT workspace_id, ticket_id, runtime_id, worker_id, assignment_id FROM test_worker_assignments WHERE status = 'active';").unwrap(); + conn.execute("INSERT INTO test_worker_assignments VALUES ('workspace', ?1, 'runtime-a', 'worker-a', 'assignment-a', 'active')", params![id]).unwrap(); + conn +} + +#[test] +fn assigned_worker_requires_exact_live_identity_but_recorded_replay_survives_release() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let conn = assignment_fixture(&temp, &initial.meta.id); + let operation = request(&initial, "close", close(TicketClosureReason::Completed)); + for key in [ + "source_runtime_id", + "source_worker_id", + "source_assignment_id", + "source_actor_role", + ] { + let mut wrong = assignment_source(); + wrong.insert(key.into(), "mismatch".into()); + assert!( + store + .clone() + .with_event_attributes(wrong) + .update_case(&initial.meta.id, operation.clone()) + .is_err() + ); + } + for key in [ + "source_runtime_id", + "source_worker_id", + "source_assignment_id", + ] { + let mut missing = assignment_source(); + missing.remove(key); + assert!( + store + .clone() + .with_event_attributes(missing) + .update_case(&initial.meta.id, operation.clone()) + .is_err() + ); + } + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); + let source = store.clone().with_event_attributes(assignment_source()); + let closed = source + .update_case(&initial.meta.id, operation.clone()) + .unwrap(); + // The Ticket domain does not itself release or stop workers. Parent Workspace triggers own this. + assert_eq!( + conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r + .get::<_, String>( + 0 + )) + .unwrap(), + "active" + ); + conn.execute("UPDATE test_worker_assignments SET status = 'released'", []) + .unwrap(); + let reopened = store + .update_case( + &initial.meta.id, + request(&closed, "reopen", TicketCaseAction::Reopen), + ) + .unwrap(); + assert_eq!( + source.update_case(&initial.meta.id, operation).unwrap(), + reopened + ); + assert!( + source + .update_case( + &initial.meta.id, + request(&reopened, "new-close", close(TicketClosureReason::Unknown)) + ) + .is_err() + ); + assert_eq!( + conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r + .get::<_, String>( + 0 + )) + .unwrap(), + "released" + ); +} + +#[test] +fn content_digest_changes_with_targets_or_requirements_not_case_judgment_history() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let digest = ticket_content_digest(&initial); + let closed = store + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::Unknown)), + ) + .unwrap(); + assert_eq!(ticket_content_digest(&closed), digest); + let edited = store + .edit_item( + initial.meta.id.clone().into(), + TicketItemEdit { + body: Some(MarkdownText::new("New criteria")), + ..Default::default() + }, + ) + .unwrap(); + assert_ne!(ticket_content_digest(&edited), digest); + let restored = store + .edit_item( + initial.meta.id.clone().into(), + TicketItemEdit { + body: Some(initial.document.body.clone()), + ..Default::default() + }, + ) + .unwrap(); + assert_eq!(ticket_content_digest(&restored), digest); + let targeted = store + .edit_item( + initial.meta.id.clone().into(), + TicketItemEdit { + targets: Some(TicketTargetsEdit::Set { + targets: vec![TicketTarget { + repository_key: "main".into(), + ref_selector: None, + access: TicketTargetAccess::ReadOnly, + }], + }), + ..Default::default() + }, + ) + .unwrap(); + assert_ne!(ticket_content_digest(&targeted), digest); +} + +#[test] +fn external_case_request_requires_an_explicit_nullable_closure_observation() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let original = request(&initial, "close", close(TicketClosureReason::Unknown)); + let mut json = serde_json::to_value(&original).unwrap(); + assert_eq!( + serde_json::from_value::(json.clone()).unwrap(), + original + ); + json.as_object_mut() + .unwrap() + .remove("expected_closure_operation_key"); + assert!(serde_json::from_value::(json).is_err()); +} + +#[test] +fn conflicting_event_context_rolls_back_case_judgment_instead_of_corrupting_its_receipt() { + let temp = TempDir::new().unwrap(); + let store = backend(&temp); + let initial = create(&store); + let context = store.clone().with_event_attributes(BTreeMap::from([( + "operation_key".into(), + "wrong-key".into(), + )])); + assert!( + context + .update_case( + &initial.meta.id, + request(&initial, "close", close(TicketClosureReason::Unknown)) + ) + .is_err() + ); + assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial); +} diff --git a/crates/ticket/tests/fixtures/schema7.sql b/crates/ticket/tests/fixtures/schema7.sql new file mode 100644 index 000000000..0a1d1ea3f --- /dev/null +++ b/crates/ticket/tests/fixtures/schema7.sql @@ -0,0 +1,134 @@ +-- Frozen standalone schema7 fixture; deliberate persisted compatibility contract. +-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority. +CREATE TABLE typed_ticket_artifacts ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, relative_path), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_attributes ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, key), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_event_references ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal), + FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_events ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + event_index INTEGER NOT NULL, + kind TEXT NOT NULL, + author TEXT, + at TEXT, + status TEXT, + from_state TEXT, + to_state TEXT, + reason TEXT, + state_field TEXT, + heading TEXT, + body TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, event_index), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_labels ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_orchestration_plans ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + record_id TEXT NOT NULL, + kind TEXT NOT NULL, + related_ticket TEXT, + note TEXT, + accepted_summary TEXT, + accepted_branch TEXT, + accepted_worktree TEXT, + accepted_role_plan TEXT, + author TEXT NOT NULL, + at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, record_id), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_raw_frontmatter ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_relations ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, kind, target), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_ticket_risk_flags ( + workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL, + PRIMARY KEY (workspace_id, ticket_id, ordinal), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE typed_tickets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + slug TEXT NOT NULL, + title TEXT NOT NULL, + status TEXT NOT NULL, + kind TEXT NOT NULL, + priority TEXT NOT NULL, + body TEXT NOT NULL, + created_at TEXT, + updated_at TEXT, + assignee TEXT, + readiness TEXT, + workflow_state TEXT NOT NULL, + workflow_state_explicit INTEGER NOT NULL, + queued_by TEXT, + queued_at TEXT, + resolution TEXT, + PRIMARY KEY (workspace_id, ticket_id) +); +CREATE TABLE typed_ticket_targets ( + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + ordinal INTEGER NOT NULL, + repository_key TEXT NOT NULL, + ref_selector TEXT, + access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')), + PRIMARY KEY (workspace_id, ticket_id, ordinal), + UNIQUE (workspace_id, ticket_id, repository_key), + FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE +); +CREATE TABLE "workspace_resource_key_counters" ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + next_sequence INTEGER NOT NULL CHECK (next_sequence > 0), + PRIMARY KEY (workspace_id, resource_kind) + ); +CREATE TABLE "workspace_resource_keys" ( + workspace_id TEXT NOT NULL, + resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')), + resource_id TEXT NOT NULL, + sequence INTEGER NOT NULL CHECK (sequence > 0), + resource_key TEXT NOT NULL, + allocated_at TEXT NOT NULL, + PRIMARY KEY (workspace_id, resource_kind, resource_id), + UNIQUE (workspace_id, resource_kind, sequence), + UNIQUE (workspace_id, resource_key) + ); +CREATE INDEX idx_workspace_resource_keys_reverse + ON workspace_resource_keys(workspace_id, resource_kind, resource_key); +CREATE INDEX typed_ticket_events_workspace_kind_ticket + ON typed_ticket_events(workspace_id, kind, ticket_id, event_index); +CREATE INDEX typed_ticket_relations_workspace_source_kind + ON typed_ticket_relations(workspace_id, ticket_id, kind, target); +CREATE INDEX typed_ticket_relations_workspace_target_kind + ON typed_ticket_relations(workspace_id, target, kind, ticket_id); +CREATE INDEX typed_tickets_workspace_created + ON typed_tickets(workspace_id, created_at DESC, ticket_id); +CREATE INDEX typed_tickets_workspace_state_updated + ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id); +CREATE INDEX typed_tickets_workspace_title + ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id); +CREATE INDEX typed_tickets_workspace_updated + ON typed_tickets(workspace_id, updated_at DESC, ticket_id); diff --git a/crates/ticket/tests/migration.rs b/crates/ticket/tests/migration.rs new file mode 100644 index 000000000..7909a48ac --- /dev/null +++ b/crates/ticket/tests/migration.rs @@ -0,0 +1,252 @@ +//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots +//! migrate independently to schema9, preserving original evidence and routing handoff. +use rusqlite::{Connection, params}; +use tempfile::TempDir; +use ticket::*; + +fn schema7(connection: &Connection) { + connection + .execute_batch(include_str!("fixtures/schema7.sql")) + .unwrap(); + connection.execute_batch("CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL); + INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'legacy');").unwrap(); +} + +#[test] +fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_without_success_inference() + { + let temp = TempDir::new().unwrap(); + let path = temp.path().join("tickets.db"); + let connection = Connection::open(&path).unwrap(); + schema7(&connection); + connection + .execute_batch("CREATE TABLE legacy_worker_status (ticket_id TEXT, status TEXT);") + .unwrap(); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit, assignee, queued_by, queued_at, resolution) VALUES ('workspace', ?1, ?1, ?1, 'open', 'task', 'P2', 'criteria', ?1, 1, 'retained-assignee', 'legacy-router', 'legacy-queue-time', 'Original decision text')", params![state]).unwrap(); + connection.execute("INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, kind, from_state, to_state, reason, author, body) VALUES ('workspace', ?1, 0, 'state_changed', 'planning', ?1, 'original reason', 'legacy-actor', 'legacy body')", params![state]).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'operation_key', 'legacy-receipt')", params![state]).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'request_fingerprint', 'legacy-fingerprint')", params![state]).unwrap(); + // Actual pre-content receipt spelling: preserve it as historical input, + // never promote an old counter fingerprint into a current content CAS. + connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'expected_content_digest', 'old-input')", params![state]).unwrap(); + connection.execute( + "UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'", + [state], + ).unwrap(); + connection.execute("INSERT INTO typed_ticket_event_references VALUES ('workspace', ?1, 0, 0, 'merge_request', 'MR-existing')", params![state]).unwrap(); + connection.execute("INSERT INTO typed_ticket_targets VALUES ('workspace', ?1, 0, 'main', 'develop', 'read_write')", params![state]).unwrap(); + connection + .execute( + "INSERT INTO legacy_worker_status VALUES (?1, 'released')", + params![state], + ) + .unwrap(); + } + // Snapshot evidence before migration, including untyped `done` and existing MR references. + let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::>().unwrap(); + let old_attributes: Vec<(String, String, String)> = connection + .prepare( + "SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key", + ) + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap() + .collect::>() + .unwrap(); + migrate_sqlite_ticket_schema(&connection).unwrap(); + let preserved_attributes: Vec<(String, String, String)> = connection + .prepare( + "SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key", + ) + .unwrap() + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .unwrap() + .collect::>() + .unwrap(); + assert_eq!(old_attributes, preserved_attributes); + verify_sqlite_ticket_schema(&connection).unwrap(); + let marker: i64 = connection + .query_row("SELECT version FROM ticket_schema_migrations", [], |row| { + row.get(0) + }) + .unwrap(); + assert_eq!(marker, 9); + let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::>().unwrap(); + assert_eq!(after_events, historical_events); + let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap(); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + let ticket = store.show(state.into()).unwrap(); + let expected_state = if matches!(state, "done" | "closed") { + TicketState::Closed + } else { + TicketState::Open + }; + assert_eq!(ticket.meta.workflow_state, expected_state); + let old_key_request = TicketCaseUpdate { + expected_content_digest: ticket_content_digest(&ticket), + expected_state, + expected_closure_operation_key: ticket.closure_operation_key.clone(), + operation_key: "legacy-receipt".into(), + action: if expected_state == TicketState::Closed { + TicketCaseAction::CorrectClosure { + reason: TicketClosureReason::Unknown, + } + } else { + TicketCaseAction::Close { + reason: TicketClosureReason::Unknown, + } + }, + explanation: "Cannot reinterpret a frozen operation receipt".into(), + references: vec![], + author: Some("human".into()), + }; + assert!(matches!( + store.update_case(state, old_key_request), + Err(TicketError::OperationFingerprintMismatch { .. }) + )); + assert_eq!(store.show(state.into()).unwrap(), ticket); + let legacy = ticket.legacy_workflow_snapshot.as_ref().unwrap(); + assert_eq!(legacy.workflow_state, state); + assert!(legacy.workflow_state_explicit); + assert_eq!(legacy.status, "open"); + assert_eq!(legacy.queued_by.as_deref(), Some("legacy-router")); + assert_eq!(legacy.queued_at.as_deref(), Some("legacy-queue-time")); + assert_eq!(legacy.resolution.as_deref(), Some("Original decision text")); + assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee")); + assert_eq!(ticket.meta.targets[0].repository_key, "main"); + assert_eq!(ticket.events[0].from.as_deref(), Some("planning")); + assert_eq!(ticket.events[0].to.as_deref(), Some(state)); + assert_eq!(ticket.events[0].references[0].target, "MR-existing"); + assert_eq!( + ticket.events[0].attributes["request_fingerprint"], + "legacy-fingerprint" + ); + if expected_state == TicketState::Closed { + let judgment = ticket.current_closure.as_ref().unwrap(); + assert_eq!( + judgment.reason, + TicketClosureReason::Unknown, + "old done must never mean successful completion" + ); + assert_eq!(judgment.explanation, "Original decision text"); + let reopened = store + .update_case( + state, + TicketCaseUpdate { + expected_content_digest: ticket_content_digest(&ticket), + expected_state, + expected_closure_operation_key: ticket.closure_operation_key.clone(), + operation_key: "reopen-migrated".into(), + action: TicketCaseAction::Reopen, + explanation: "Reconsider the case".into(), + references: Vec::new(), + author: Some("human".into()), + }, + ) + .unwrap(); + assert!(reopened.current_closure.is_none()); + assert_eq!(reopened.case_history[0], ticket.case_history[0]); + assert_eq!( + reopened.legacy_workflow_snapshot, + ticket.legacy_workflow_snapshot + ); + } else { + assert!(ticket.current_closure.is_none()); + } + } + let released: i64 = connection + .query_row( + "SELECT COUNT(*) FROM legacy_worker_status WHERE status = 'released'", + [], + |row| row.get(0), + ) + .unwrap(); + assert_eq!(released, 6); + assert!( + connection + .execute( + "UPDATE typed_ticket_legacy_workflow_snapshot SET snapshot_json = '{}'", + [] + ) + .is_err() + ); + migrate_sqlite_ticket_schema(&connection).unwrap(); + assert_eq!( + connection + .query_row( + "SELECT COUNT(*) FROM typed_ticket_legacy_workflow_snapshot", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 6 + ); +} + +#[test] +fn unknown_legacy_state_rolls_back_the_independent_case_migration() { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) VALUES ('workspace', 'unknown', 'unknown', 'unknown', 'open', 'task', 'P2', 'body', 'future-state', 1)", []).unwrap(); + assert!(migrate_sqlite_ticket_schema(&connection).is_err()); + assert_eq!( + connection + .query_row("SELECT version FROM ticket_schema_migrations", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 7 + ); + assert_eq!( + connection + .query_row("SELECT workflow_state FROM typed_tickets", [], |row| row + .get::<_, String>( + 0 + )) + .unwrap(), + "future-state" + ); + assert_eq!( + connection + .query_row( + "SELECT COUNT(*) FROM sqlite_master WHERE name = 'typed_ticket_case_history'", + [], + |row| row.get::<_, i64>(0) + ) + .unwrap(), + 0 + ); +} + +#[test] +fn schema8_tag_composition_is_rejected_until_explicitly_integrated() { + let connection = Connection::open_in_memory().unwrap(); + schema7(&connection); + connection + .execute("UPDATE ticket_schema_migrations SET version = 8", []) + .unwrap(); + assert!(migrate_sqlite_ticket_schema(&connection).is_err()); + assert_eq!( + connection + .query_row("SELECT version FROM ticket_schema_migrations", [], |row| { + row.get::<_, i64>(0) + }) + .unwrap(), + 8 + ); +} diff --git a/crates/worker/src/feature/builtin/orchestration.rs b/crates/worker/src/feature/builtin/orchestration.rs index bf864d6c9..abbdca4e2 100644 --- a/crates/worker/src/feature/builtin/orchestration.rs +++ b/crates/worker/src/feature/builtin/orchestration.rs @@ -247,13 +247,13 @@ fn validate_relative_cwd(value: String) -> Result { mod tests { use std::sync::Mutex; - use ticket::{TicketError, TicketWorkflowState}; + use ticket::{TicketError, TicketState}; use super::*; use crate::feature::builtin::ticket::TicketHandoff; use crate::worker::{WorkspaceClientError, WorkspaceResponse}; - struct FixedTicketService(TicketWorkflowState); + struct FixedTicketService(TicketState); impl TicketService for FixedTicketService { fn ticket_handoff(&self, ticket_ref: &str) -> Result { @@ -295,7 +295,7 @@ mod tests { }) } - fn tool(service: Arc, state: TicketWorkflowState) -> SpawnTicketWorkerTool { + fn tool(service: Arc, state: TicketState) -> SpawnTicketWorkerTool { SpawnTicketWorkerTool { ticket_service: Arc::new(FixedTicketService(state)), worker_service: service, @@ -315,7 +315,7 @@ mod tests { }, {"alias": "docs", "working_directory_id": "workdir-2"} ]); - let output = tool(service.clone(), TicketWorkflowState::Queued) + let output = tool(service.clone(), TicketState::Open) .execute( &input.to_string(), ToolExecutionContext::new("call-7", "batch-1", 0), @@ -372,7 +372,7 @@ mod tests { input["workdir_attachments"] = serde_json::json!([]); input["flow"] = serde_json::Value::Null; } - tool(service.clone(), TicketWorkflowState::Queued) + tool(service.clone(), TicketState::Open) .execute( &input.to_string(), ToolExecutionContext::new("call-empty", "batch-1", 0), @@ -395,7 +395,7 @@ mod tests { async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() { let service = Arc::new(RecordingService::default()); let definition = definition( - Arc::new(FixedTicketService(TicketWorkflowState::Ready)), + Arc::new(FixedTicketService(TicketState::Open)), service.clone(), ); let (meta, tool) = definition(); @@ -434,33 +434,35 @@ mod tests { } #[tokio::test] - async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() { - let service = Arc::new(RecordingService { - response: Some(WorkspaceResponse { - status: 409, - body: "Backend rejected Ticket claim".into(), - }), - ..Default::default() - }); - let error = tool(service.clone(), TicketWorkflowState::Planning) - .execute( - &input().to_string(), - ToolExecutionContext::new("call-claim", "batch-1", 0), - ) - .await - .unwrap_err(); - assert_eq!(service.requests.lock().unwrap().len(), 1); - assert!( - error - .to_string() - .contains("HTTP 409: Backend rejected Ticket claim") - ); + async fn spawn_ticket_worker_defers_claim_eligibility_to_backend_not_case_state() { + for state in [TicketState::Open, TicketState::Closed] { + let service = Arc::new(RecordingService { + response: Some(WorkspaceResponse { + status: 409, + body: "Backend rejected Ticket claim".into(), + }), + ..Default::default() + }); + let error = tool(service.clone(), state) + .execute( + &input().to_string(), + ToolExecutionContext::new("call-claim", "batch-1", 0), + ) + .await + .unwrap_err(); + assert_eq!(service.requests.lock().unwrap().len(), 1); + assert!( + error + .to_string() + .contains("HTTP 409: Backend rejected Ticket claim") + ); + } } #[tokio::test] async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() { let service = Arc::new(RecordingService::default()); - let tool = tool(service.clone(), TicketWorkflowState::Queued); + let tool = tool(service.clone(), TicketState::Open); let cases = [ ( "profile", @@ -603,7 +605,7 @@ mod tests { #[test] fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() { let definition = definition( - Arc::new(FixedTicketService(TicketWorkflowState::Queued)), + Arc::new(FixedTicketService(TicketState::Open)), Arc::new(RecordingService::default()), ); let (meta, _) = definition(); diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index e249066b4..9834335fe 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -13,7 +13,10 @@ pub(super) struct ModelTicketQueryResponse { struct ModelTicketQueryItem { ticket: String, title: String, - state: String, + state: ticket::TicketState, + closure_operation_key: Option, + current_closure: Option, + legacy_handoff: Option, readiness: Option, priority: Option, created_at: Option, @@ -38,11 +41,15 @@ pub(super) struct ModelTicketDetail { content_digest: String, title: String, body: String, - state: String, + state: ticket::TicketState, + closure_operation_key: Option, + current_closure: Option, + legacy_handoff: Option, readiness: Option, priority: Option, created_at: Option, updated_at: Option, + closure_history: Vec, thread: Vec, relations: ModelTicketRelations, linked_objectives: Vec, @@ -216,6 +223,16 @@ struct ModelObjectiveEventPage { has_more: bool, } +fn typed_field( + root: &Map, + key: &str, +) -> Result { + let value = root + .get(key) + .ok_or_else(|| format!("Ticket projection requires {key}"))?; + serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}")) +} + pub(super) fn project_ticket_query(value: Value) -> Result { let root = object(&value, "Ticket query response")?; let page = object_field(root, "page")?; @@ -235,7 +252,10 @@ fn project_ticket_query_item(value: &Value) -> Result Result, - /// Exact workflow states. Empty means every state. + /// Exact case states (open or closed). Empty means every state. #[serde(default)] states: Vec, /// Exact typed event kinds that must occur in the bounded thread window. @@ -159,8 +153,8 @@ struct WorkspaceQueryTicketInput { /// Current authoritative Merge Request review status: none, pending, approved, /// request_changes, or unresolved_changes. review_status: Option, - /// Attention filters include done_not_closed, unresolved_review, missing_commit, - /// blocked, unblocked, ready, awaiting_review, unresolved_changes, + /// Attention filters include unresolved_review, missing_commit, + /// blocked, unblocked, awaiting_review, unresolved_changes, /// stale_after_rescope, and missing_evidence. #[serde(default)] attention: Vec, @@ -287,88 +281,58 @@ fn workspace_ticket_read_definition( #[derive(Debug, Deserialize, Serialize, JsonSchema)] #[serde(deny_unknown_fields)] -struct WorkspaceCompleteTicketInput { - /// Ticket reference. Prefer `T-*`. +struct WorkspaceTicketCaseUpdateInput { + /// Ticket reference. Prefer T-*; WIP binds this from the item route. ticket: String, - /// Stable key for exact replay of this completion decision. + /// Stable key for exact replay of this case judgment. operation_key: String, /// SHA-256 of the current editable Ticket content from ShowTicket. expected_content_digest: String, - expected_state: TicketWorkflowState, - /// Required explanation of the completion decision; MR approval is not a prerequisite. - reason: String, - /// Optional typed supporting Ticket references, independent of MR integration evidence. + expected_state: ticket::TicketState, + /// Explicitly observed null for never closed, or current closure operation key. + #[serde(deserialize_with = "deserialize_observed_closure_key")] + expected_closure_operation_key: Option, + action: ticket::TicketCaseAction, + /// Required explanation of the judgment, independent of Repository/MR evidence. + explanation: String, #[serde(default)] references: Vec, } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct WorkspaceTicketStateUpdateInput { - /// Ticket reference. Prefer `T-*`. - ticket: String, - operation_key: String, - /// SHA-256 of the current editable Ticket content from ShowTicket. - expected_content_digest: String, - expected_state: TicketWorkflowState, - /// Progress-display state only: this does not start Workers or integrate Merge Requests. - state: TicketWorkflowState, - /// Required explanation for the state decision. - reason: String, - #[serde(default)] - references: Vec, +fn deserialize_observed_closure_key<'de, D: serde::Deserializer<'de>>( + deserializer: D, +) -> Result, D::Error> { + Option::::deserialize(deserializer) } -#[derive(Debug, Deserialize, Serialize, JsonSchema)] -#[serde(deny_unknown_fields)] -struct WorkspaceTicketCloseInput { - /// Ticket reference. Prefer `T-*`; WIP binds this from the item route. - ticket: String, - /// Stable key for exact replay of this close decision. - operation_key: String, - /// SHA-256 of the current editable Ticket content from ShowTicket. - expected_content_digest: String, - expected_state: TicketWorkflowState, - /// Required explanation for closing this Ticket. - resolution: String, - #[serde(default)] - references: Vec, -} - -const TICKET_CLOSE_DESCRIPTION: &str = "Record an authorized Ticket closure decision with resolution, item/state CAS, and replay key when the requested return path calls for closure. No Merge Request or enforced state sequence is required. Backend checks actor authority, not natural-language satisfaction; this does not integrate MRs, stop/remove Workers, clean attachments, or expand Workdir grants."; -const COMPLETE_TICKET_DESCRIPTION: &str = "Record an authorized Ticket completion decision with reason, item/state CAS, and replay key when user intent calls for completion; return without conclusion need not call this tool. Merge Requests and approvals are not prerequisites. Backend checks assigned Worker/Workspace Orchestrator authority, not real judgment of natural-language satisfaction; model-supplied roles never grant authority. Conclusion ends unfinished work while retaining terminal responsibility for display; it does not grant review/merge authority or stop/remove Workers and attachments."; -const TICKET_STATE_UPDATE_DESCRIPTION: &str = "Update a Ticket progress-display state with reason, item/state CAS, and replay key. States need not follow an enforced sequence. Backend checks actor authority, not natural-language satisfaction. This does not start/stop Workers, queue dependencies, integrate Merge Requests, clean attachments, or enlarge live Workdir grants."; +const TICKET_CASE_UPDATE_DESCRIPTION: &str = "Record an authorized Ticket close, reopen, or closure-correction judgment with an explicit observed closure key, item/state CAS, and replay key. Open means the case remains to be handled, not ready/queued/inprogress; Closed does not imply success. Repository, Merge Request and approval evidence are not prerequisites. Return without conclusion need not call this tool. Backend checks actor authority, not real judgment of natural-language satisfaction; actor identity is injected by the server. This does not start/stop Workers, restore execution rights, change assignments, grant resources, approve or integrate Merge Requests."; #[derive(Clone, Copy)] enum WorkspaceTicketDecisionKind { - Complete, - UpdateState, - Close, + CaseUpdate, } impl WorkspaceTicketDecisionKind { fn name(self) -> &'static str { - match self { - Self::Complete => "CompleteTicket", - Self::UpdateState => "TicketWorkflowState", - Self::Close => "TicketClose", - } + "TicketCaseUpdate" } - fn description(self) -> &'static str { - match self { - Self::Complete => COMPLETE_TICKET_DESCRIPTION, - Self::UpdateState => TICKET_STATE_UPDATE_DESCRIPTION, - Self::Close => TICKET_CLOSE_DESCRIPTION, - } + TICKET_CASE_UPDATE_DESCRIPTION } - fn schema(self) -> Value { - match self { - Self::Complete => json!(schemars::schema_for!(WorkspaceCompleteTicketInput)), - Self::UpdateState => json!(schemars::schema_for!(WorkspaceTicketStateUpdateInput)), - Self::Close => json!(schemars::schema_for!(WorkspaceTicketCloseInput)), + let mut schema = json!(schemars::schema_for!(WorkspaceTicketCaseUpdateInput)); + // Observation must be present even when it is explicitly null. Keep the + // Option's nullable wire schema instead of treating it as a String. + let required = schema["required"] + .as_array_mut() + .expect("object schema required fields"); + if !required + .iter() + .any(|field| field == "expected_closure_operation_key") + { + required.push(json!("expected_closure_operation_key")); } + schema } } @@ -378,97 +342,62 @@ struct WorkspaceTicketDecisionTool { kind: WorkspaceTicketDecisionKind, } -fn validate_ticket_decision( - ticket: &str, - operation_key: &str, - content_digest: &str, - reason: &str, -) -> Result<(), ToolError> { - for (name, value) in [ - ("ticket", ticket), - ("operation_key", operation_key), - ("expected_content_digest", content_digest), - ("reason", reason), - ] { - if value.trim().is_empty() { - return Err(ToolError::InvalidArgument(format!( - "{name} must not be empty" - ))); - } - } - Ok(()) -} - #[async_trait] impl Tool for WorkspaceTicketDecisionTool { async fn execute(&self, input: &str, _: ToolExecutionContext) -> Result { - let workspace_id = self.client.workspace_id().ok_or_else(|| { + self.client.workspace_id().ok_or_else(|| { ToolError::InvalidArgument("Ticket decisions require Workspace identity".into()) })?; - let (ticket, endpoint, body) = match self.kind { - WorkspaceTicketDecisionKind::Complete => { - let value: WorkspaceCompleteTicketInput = serde_json::from_str(input) - .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; - validate_ticket_decision( - &value.ticket, - &value.operation_key, - &value.expected_content_digest, - &value.reason, - )?; - let mut body = serde_json::to_value(&value) - .map_err(|error| ToolError::Internal(error.to_string()))?; - body.as_object_mut() - .expect("typed Ticket input is an object") - .remove("ticket"); - (value.ticket, "complete", body) - } - WorkspaceTicketDecisionKind::Close => { - let value: WorkspaceTicketCloseInput = serde_json::from_str(input) - .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; - validate_ticket_decision( - &value.ticket, - &value.operation_key, - &value.expected_content_digest, - &value.resolution, - )?; - let body = json!({ - "operation_key": value.operation_key, - "expected_content_digest": value.expected_content_digest, - "expected_state": value.expected_state, - "state": TicketWorkflowState::Closed, - "reason": value.resolution, - "references": value.references, - }); - (value.ticket, "state-update", body) - } - WorkspaceTicketDecisionKind::UpdateState => { - let value: WorkspaceTicketStateUpdateInput = serde_json::from_str(input) - .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; - validate_ticket_decision( - &value.ticket, - &value.operation_key, - &value.expected_content_digest, - &value.reason, - )?; - let mut body = serde_json::to_value(&value) - .map_err(|error| ToolError::Internal(error.to_string()))?; - body.as_object_mut() - .expect("typed Ticket input is an object") - .remove("ticket"); - // Actor identity is transport-bound, never a model-visible author/role input. - (value.ticket, "state-update", body) - } - }; - let ticket: Ticket = WorkspaceHttpTicketBackend::request( - self.client.clone(), - WorkspaceRequestMethod::Post, - format!( - "/api/w/{workspace_id}/tickets/{}/{endpoint}", - WorkspaceHttpTicketBackend::ticket_path(&TicketIdOrSlug::from(ticket.as_str())) + let value: WorkspaceTicketCaseUpdateInput = serde_json::from_str(input) + .map_err(|error| ToolError::InvalidArgument(error.to_string()))?; + for (name, value) in [ + ("ticket", value.ticket.as_str()), + ("operation_key", value.operation_key.as_str()), + ( + "expected_content_digest", + value.expected_content_digest.as_str(), ), - Some(body), - ) - .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; + ("explanation", value.explanation.as_str()), + ] { + if value.trim().is_empty() { + return Err(ToolError::InvalidArgument(format!( + "{name} must not be empty" + ))); + } + } + if value + .expected_closure_operation_key + .as_deref() + .is_some_and(|key| key.trim().is_empty()) + { + return Err(ToolError::InvalidArgument( + "expected_closure_operation_key must be null or nonempty".into(), + )); + } + match &value.action { + ticket::TicketCaseAction::Close { reason } + | ticket::TicketCaseAction::CorrectClosure { reason } + if matches!(reason, ticket::TicketClosureReason::Other(text) if text.trim().is_empty()) => + { + return Err(ToolError::InvalidArgument( + "other closure reason must not be empty".into(), + )); + } + _ => {} + } + let mut body = + serde_json::to_value(&value).map_err(|error| ToolError::Internal(error.to_string()))?; + body.as_object_mut() + .expect("typed Ticket input is an object") + .remove("ticket"); + // Actor identity is transport-bound, never a model-visible author/role input. + let ticket = WorkspaceHttpTicketBackend::new(self.client.clone()) + .update_case( + &value.ticket, + serde_json::from_value(body) + .map_err(|error| ToolError::InvalidArgument(error.to_string()))?, + ) + .map_err(|error| ToolError::ExecutionFailed(error.to_string()))?; Ok(ToolOutput { summary: self.kind.name().into(), content: Some( @@ -499,9 +428,7 @@ fn workspace_ticket_decision_definition( fn workspace_ticket_tool_description(name: &str) -> String { match name { - "CompleteTicket" => COMPLETE_TICKET_DESCRIPTION.into(), - "TicketWorkflowState" => TICKET_STATE_UPDATE_DESCRIPTION.into(), - "TicketClose" => TICKET_CLOSE_DESCRIPTION.into(), + "TicketCaseUpdate" => TICKET_CASE_UPDATE_DESCRIPTION.into(), _ => ticket_tool_description(name, None), } } @@ -522,7 +449,7 @@ pub trait TicketService: Send + Sync { pub struct TicketHandoff { pub id: String, pub resource_key: String, - pub workflow_state: TicketWorkflowState, + pub workflow_state: TicketState, } fn is_canonical_ticket_resource_key(resource_key: &str) -> bool { @@ -631,7 +558,6 @@ impl TicketFeatureAccess { .iter() .copied() .filter(|name| self.allows_tool(name)) - .chain((self.authoring || self.workflow || self.thread).then_some("CompleteTicket")) .collect() } @@ -640,7 +566,7 @@ impl TicketFeatureAccess { // Workspace transport lets the Backend validate the current assignment // or author/orchestrator permission on every decision, in both modes. READ_ONLY_TOOL_NAMES.contains(&name) - || (name == "TicketWorkflowState" && (self.authoring || self.thread || self.workflow)) + || (name == "TicketCaseUpdate" && (self.authoring || self.thread || self.workflow)) || (self.authoring && AUTHORING_TOOL_NAMES.contains(&name)) || (self.thread && THREAD_TOOL_NAMES.contains(&name)) || (self.intake && INTAKE_TOOL_NAMES.contains(&name)) @@ -655,7 +581,7 @@ const AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketEditItem", "TicketMarkReady", "TicketQueue", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", ]; @@ -673,11 +599,9 @@ const WORKSPACE_AUTHORING_TOOL_NAMES: &[&str] = &[ "TicketComment", "TicketMarkReady", "TicketQueue", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketRelationRecord", "TicketRelationRemove", - "CompleteTicket", ]; #[cfg(test)] @@ -685,19 +609,16 @@ const WORKFLOW_TOOL_NAMES: &[&str] = &[ "QueryTicket", "ShowTicket", "TicketComment", - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", "TicketOrchestrationPlanRecord", "TicketOrchestrationPlanQuery", - "CompleteTicket", ]; const WORKFLOW_ADDITIONAL_TOOL_NAMES: &[&str] = &[ - "TicketWorkflowState", - "TicketClose", + "TicketCaseUpdate", "TicketDependencyCheck", "TicketRelationRecord", "TicketRelationRemove", @@ -972,49 +893,6 @@ impl WorkspaceHttpTicketBackend { Ok(TicketBackendOperationResult::Unit) } - fn legacy_state_request( - client: Arc, - base: &str, - id: &TicketIdOrSlug, - change: TicketStateChange, - ) -> TicketResult { - // Preserve the caller's original `from`: refreshing it from the snapshot - // would turn a stale transition into a successful write. - let expected_state = TicketWorkflowState::parse(&change.from).ok_or_else(|| { - TicketError::Conflict(format!("invalid Ticket from state: {}", change.from)) - })?; - let state = TicketWorkflowState::parse(&change.to).ok_or_else(|| { - TicketError::Conflict(format!("invalid Ticket to state: {}", change.to)) - })?; - let reason = [change.reason.as_str(), change.body.as_str()] - .into_iter() - .map(str::trim) - .filter(|part| !part.is_empty()) - .collect::>() - .join("\n\n"); - if reason.is_empty() { - return Err(TicketError::Conflict( - "Ticket state decision requires a reason".into(), - )); - } - let snapshot: Ticket = Self::request_unprojected( - client, - WorkspaceRequestMethod::Get, - format!("{base}/{}/record", Self::ticket_path(id)), - None, - )?; - serde_json::to_value(ticket::TicketStateUpdate { - operation_key: uuid::Uuid::now_v7().to_string(), - expected_content_digest: ticket::ticket_content_digest(&snapshot), - expected_state, - state, - reason: reason.into(), - references: change.references, - author: change.author, - }) - .map_err(|error| TicketError::Conflict(format!("serialize Ticket state decision: {error}"))) - } - fn invoke_client( client: Arc, workspace_id: String, @@ -1069,6 +947,11 @@ impl WorkspaceHttpTicketBackend { Ok(TicketBackendOperationResult::Ticket(ticket)) } TicketBackendOperation::Create { input } => { + if input.workflow_state == Some(TicketState::Closed) { + return Err(TicketError::Conflict( + "Tickets must be created open; closing requires a case judgment".into(), + )); + } let ticket = Self::request( client, WorkspaceRequestMethod::Post, @@ -1107,15 +990,9 @@ impl WorkspaceHttpTicketBackend { TicketError::Conflict(format!("serialize Ticket event: {error}")) })?), ), - TicketBackendOperation::AddStateChanged { id, change } => { - let body = Self::legacy_state_request(client.clone(), &base, &id, change)?; - Self::request_unit( - client, - WorkspaceRequestMethod::Post, - format!("{base}/{}/state-changes", Self::ticket_path(&id)), - Some(body), - ) - } + TicketBackendOperation::AddStateChanged { .. } => Err(TicketError::Conflict( + "Case changes require an explicit TicketCaseUpdate judgment".into(), + )), TicketBackendOperation::AddIntakeSummary { id, summary } => Self::request_unit( client, WorkspaceRequestMethod::Post, @@ -1124,57 +1001,31 @@ impl WorkspaceHttpTicketBackend { TicketError::Conflict(format!("serialize Ticket intake summary: {error}")) })?), ), - TicketBackendOperation::SetStateField { id, field, change } => { - if field != "workflow_state" && field != "state" { - return Err(TicketError::Conflict(format!( - "unsupported Ticket state field: {field}" - ))); - } - let body = Self::legacy_state_request(client.clone(), &base, &id, change)?; - Self::request_unit( - client, + TicketBackendOperation::UpdateCase { ticket, request } => { + let mut body = serde_json::to_value(request).map_err(|error| { + TicketError::Conflict(format!("serialize Ticket case judgment: {error}")) + })?; + // Domain callers cannot supply HTTP actor authority either. + body.as_object_mut() + .expect("typed case update is an object") + .remove("author"); + let encoded_ticket = Self::ticket_path(&TicketIdOrSlug::from(ticket.as_str())); + let _: ticket::TicketSummary = Self::request( + client.clone(), WorkspaceRequestMethod::Post, - format!( - "{base}/{}/state-fields/{}", - Self::ticket_path(&id), - Self::ticket_path(&TicketIdOrSlug::Query(field)) - ), + format!("/workspaces/{workspace_id}/tickets/{encoded_ticket}/case"), Some(body), - ) - } - TicketBackendOperation::SetWorkflowState { id, change } => { - let body = Self::legacy_state_request(client.clone(), &base, &id, change)?; - Self::request_unit( + )?; + // The mutation receipt returns a bounded summary. Read the full + // current authority; never fabricate history from that summary. + Self::request( client, - WorkspaceRequestMethod::Post, - format!("{base}/{}/workflow-state", Self::ticket_path(&id)), - Some(body), + WorkspaceRequestMethod::Get, + format!("{base}/{encoded_ticket}/record"), + None, ) + .map(TicketBackendOperationResult::Ticket) } - TicketBackendOperation::UpdateState { ticket, request } => Self::request( - client, - WorkspaceRequestMethod::Post, - format!( - "{base}/{}/state-update", - Self::ticket_path(&TicketIdOrSlug::from(ticket.as_str())) - ), - Some(serde_json::to_value(request).map_err(|error| { - TicketError::Conflict(format!("serialize Ticket state update: {error}")) - })?), - ) - .map(TicketBackendOperationResult::Ticket), - TicketBackendOperation::Complete { ticket, request } => Self::request( - client, - WorkspaceRequestMethod::Post, - format!( - "{base}/{}/complete", - Self::ticket_path(&TicketIdOrSlug::from(ticket.as_str())) - ), - Some(serde_json::to_value(request).map_err(|error| { - TicketError::Conflict(format!("serialize Ticket completion: {error}")) - })?), - ) - .map(TicketBackendOperationResult::Ticket), TicketBackendOperation::MarkReady { id, request } => Self::request( client, WorkspaceRequestMethod::Post, @@ -1191,36 +1042,6 @@ impl WorkspaceHttpTicketBackend { None, ) .map(TicketBackendOperationResult::QueueOutcome), - TicketBackendOperation::Close { id, resolution } => { - if resolution.as_str().trim().is_empty() { - return Err(TicketError::Conflict( - "Ticket close requires a resolution".into(), - )); - } - let snapshot: Ticket = Self::request_unprojected( - client.clone(), - WorkspaceRequestMethod::Get, - format!("{base}/{}/record", Self::ticket_path(&id)), - None, - )?; - let body = serde_json::to_value(ticket::TicketCompletion { - operation_key: uuid::Uuid::now_v7().to_string(), - expected_content_digest: ticket::ticket_content_digest(&snapshot), - expected_state: snapshot.meta.workflow_state, - reason: resolution.as_str().to_owned(), - references: Vec::new(), - author: None, - }) - .map_err(|error| { - TicketError::Conflict(format!("serialize Ticket close: {error}")) - })?; - Self::request_unit( - client, - WorkspaceRequestMethod::Post, - format!("{base}/{}/workflow/close", Self::ticket_path(&id)), - Some(body), - ) - } TicketBackendOperation::AddTicketRelation { id, relation } => { let source_resource_key = Self::resolve_ticket_resource_key(client.clone(), &base, &id)?; @@ -1408,44 +1229,9 @@ impl TicketBackend for WorkspaceHttpTicketBackend { } } - fn set_state_field( - &self, - id: TicketIdOrSlug, - field: &str, - change: TicketStateChange, - ) -> TicketResult<()> { - match self.invoke(TicketBackendOperation::SetStateField { - id, - field: field.to_string(), - change, - })? { - TicketBackendOperationResult::Unit => Ok(()), - other => Err(TicketError::Conflict(format!( - "unexpected ticket backend response: {other:?}" - ))), - } - } - - fn set_workflow_state( - &self, - id: TicketIdOrSlug, - change: TicketStateChange, - ) -> TicketResult<()> { - match self.invoke(TicketBackendOperation::SetWorkflowState { id, change })? { - TicketBackendOperationResult::Unit => Ok(()), - other => Err(TicketError::Conflict(format!( - "unexpected ticket backend response: {other:?}" - ))), - } - } - - fn update_state( - &self, - ticket: &str, - request: ticket::TicketStateUpdate, - ) -> TicketResult { + fn update_case(&self, ticket: &str, request: ticket::TicketCaseUpdate) -> TicketResult { expect_ticket_result!( - self.invoke(TicketBackendOperation::UpdateState { + self.invoke(TicketBackendOperation::UpdateCase { ticket: ticket.to_string(), request }), @@ -1478,15 +1264,6 @@ impl TicketBackend for WorkspaceHttpTicketBackend { ) } - fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> TicketResult<()> { - match self.invoke(TicketBackendOperation::Close { id, resolution })? { - TicketBackendOperationResult::Unit => Ok(()), - other => Err(TicketError::Conflict(format!( - "unexpected ticket backend response: {other:?}" - ))), - } - } - fn add_ticket_relation( &self, id: TicketIdOrSlug, @@ -1625,20 +1402,8 @@ fn native_ticket_operation(tool_name: &str) -> Option { identity_field: Some("ticket"), mutating: true, }, - "TicketWorkflowState" => NativeTicketOperation { - operation: "transition", - surface: NativeTicketSurface::Item, - identity_field: Some("ticket"), - mutating: true, - }, - "CompleteTicket" => NativeTicketOperation { - operation: "complete", - surface: NativeTicketSurface::Item, - identity_field: Some("ticket"), - mutating: true, - }, - "TicketClose" => NativeTicketOperation { - operation: "close", + "TicketCaseUpdate" => NativeTicketOperation { + operation: "case_update", surface: NativeTicketSurface::Item, identity_field: Some("ticket"), mutating: true, @@ -1693,7 +1458,7 @@ pub(crate) fn enabled_ticket_definitions( ) -> Vec { let backend = TicketToolBackend::new(WorkspaceHttpTicketBackend::new(client.clone())); let allowed = access.tool_names(); - let mut definitions: Vec = ticket_tools(backend) + ticket_tools(backend) .into_iter() .filter_map(|definition| { let (meta, _) = definition(); @@ -1707,25 +1472,14 @@ pub(crate) fn enabled_ticket_definitions( "ShowTicket" => { workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show) } - "TicketClose" => workspace_ticket_decision_definition( + "TicketCaseUpdate" => workspace_ticket_decision_definition( client.clone(), - WorkspaceTicketDecisionKind::Close, - ), - "TicketWorkflowState" => workspace_ticket_decision_definition( - client.clone(), - WorkspaceTicketDecisionKind::UpdateState, + WorkspaceTicketDecisionKind::CaseUpdate, ), _ => definition, }) }) - .collect(); - if allowed.contains(&"CompleteTicket") { - definitions.push(workspace_ticket_decision_definition( - client, - WorkspaceTicketDecisionKind::Complete, - )); - } - definitions + .collect() } fn native_ticket_tools( @@ -2387,13 +2141,16 @@ mod tests { // inject mutation rejection independently of that successful read. if request.method == WorkspaceRequestMethod::Get { response.status = 200; + } else if response.status == 200 && request.path.ends_with("/case") { + let record: Value = serde_json::from_str(&response.body).unwrap(); + response.body = record["meta"].to_string(); } self.requests.lock().unwrap().push(request); Ok(response) } } - fn decision_client(status: u16, state: TicketWorkflowState) -> Arc { + fn decision_client(status: u16, state: TicketState) -> Arc { let temp = tempfile::tempdir().unwrap(); let db = ticket::SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap(); @@ -2412,511 +2169,248 @@ mod tests { fn decision_input() -> Value { json!({ - "ticket": "T-718", "operation_key": "decision-1", - "expected_content_digest": "content_digest-1", "expected_state": "planning", - "reason": "No repository changes are needed" + "ticket": "T-739", "operation_key": "case-1", + "expected_content_digest": "digest-1", "expected_state": "open", + "expected_closure_operation_key": null, + "action": {"action": "close", "reason": "not_planned"}, + "explanation": "No repository changes are needed" }) } - fn close_input() -> Value { + #[test] + fn case_backend_sends_explicit_judgment_without_domain_author_claim() { + let client = decision_client(200, TicketState::Closed); + let backend = WorkspaceHttpTicketBackend::new(client.clone()); let mut input = decision_input(); - let reason = input.as_object_mut().unwrap().remove("reason").unwrap(); - input["resolution"] = reason; - input - } - - fn legacy_change() -> TicketStateChange { - serde_json::from_value(json!({ - "from": "planning", "to": "closed", "reason": "Decision", - "body": "Supporting explanation", "references": [], "author": "caller" - })) - .unwrap() - } - - #[test] - fn legacy_ticket_state_adapters_send_strong_cas_without_refreshing_stale_from() { - for endpoint in [ - "state-changes", - "workflow-state", - "state-fields/workflow_state", - "state-fields/state", - ] { - // The observed state is ready, deliberately different from change.from. - let client = decision_client(200, TicketWorkflowState::Ready); - let snapshot: Ticket = serde_json::from_str(&client.response.body).unwrap(); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - let id = TicketIdOrSlug::from("T-718"); - match endpoint { - "state-changes" => backend.add_state_changed(id, legacy_change()), - "workflow-state" => backend.set_workflow_state(id, legacy_change()), - "state-fields/workflow_state" => { - backend.set_state_field(id, "workflow_state", legacy_change()) - } - _ => backend.set_state_field(id, "state", legacy_change()), - } - .unwrap(); - let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 2); - assert_eq!(requests[0].method, WorkspaceRequestMethod::Get); - assert_eq!(requests[0].path, "/api/w/workspace/tickets/T-718/record"); - assert_eq!(requests[1].method, WorkspaceRequestMethod::Post); - assert_eq!( - requests[1].path, - format!("/api/w/workspace/tickets/T-718/{endpoint}") - ); - let body: Value = serde_json::from_str(requests[1].body.as_deref().unwrap()).unwrap(); - let request: ticket::TicketStateUpdate = serde_json::from_value(body.clone()).unwrap(); - assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok()); - assert_eq!( - request.expected_content_digest, - ticket::ticket_content_digest(&snapshot) - ); - assert_eq!(request.expected_state, TicketWorkflowState::Planning); - assert_eq!(request.state, TicketWorkflowState::Closed); - assert_eq!(request.reason, "Decision\n\nSupporting explanation"); - assert_eq!(body["author"], "caller"); - assert_eq!(body["references"], json!([])); - assert!(body.get("from").is_none()); - assert!(body.get("to").is_none()); - } - } - - #[test] - fn legacy_ticket_state_adapter_parses_each_original_display_state() { - let client = decision_client(200, TicketWorkflowState::Closed); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - TicketWorkflowState::Done, - TicketWorkflowState::Closed, - ] { - let mut change = legacy_change(); - change.from = state.as_str().into(); - change.to = state.as_str().into(); - backend - .set_workflow_state(TicketIdOrSlug::from("T-718"), change) - .unwrap(); - let requests = client.requests.lock().unwrap(); - let request: ticket::TicketStateUpdate = - serde_json::from_str(requests.last().unwrap().body.as_deref().unwrap()).unwrap(); - assert_eq!(request.expected_state, state); - assert_eq!(request.state, state); - } - } - - #[test] - fn legacy_ticket_adapters_reject_invalid_fields_states_and_empty_reasons_before_dispatch() { - let client = decision_client(200, TicketWorkflowState::Planning); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - assert!( - backend - .set_state_field( - TicketIdOrSlug::from("T-718"), - "approval_state", - legacy_change() - ) - .is_err() - ); - for field in ["from", "to"] { - let mut change = legacy_change(); - if field == "from" { - change.from = "approved".into(); - } else { - change.to = "approved".into(); - } - assert!( - backend - .set_workflow_state(TicketIdOrSlug::from("T-718"), change) - .is_err() - ); - } - let mut change = legacy_change(); - change.reason = " ".into(); - change.body = " \n ".into(); - assert!( - backend - .add_state_changed(TicketIdOrSlug::from("T-718"), change) - .is_err() - ); - assert!( - backend - .close(TicketIdOrSlug::from("T-718"), " ".into()) - .is_err() - ); - assert!(client.requests.lock().unwrap().is_empty()); - } - - #[test] - fn legacy_ticket_close_snapshots_cas_and_generates_distinct_completion_keys() { - let client = decision_client(200, TicketWorkflowState::Ready); - let snapshot: Ticket = serde_json::from_str(&client.response.body).unwrap(); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - for _ in 0..2 { - backend - .close( - TicketIdOrSlug::from("T-718"), - "Resolved without repository changes".into(), - ) - .unwrap(); - } - let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 4); - let mut keys = BTreeSet::new(); - for pair in requests.chunks_exact(2) { - assert_eq!(pair[0].method, WorkspaceRequestMethod::Get); - assert_eq!(pair[0].path, "/api/w/workspace/tickets/T-718/record"); - assert_eq!(pair[1].method, WorkspaceRequestMethod::Post); - assert_eq!( - pair[1].path, - "/api/w/workspace/tickets/T-718/workflow/close" - ); - let request: ticket::TicketCompletion = - serde_json::from_str(pair[1].body.as_deref().unwrap()).unwrap(); - assert!(uuid::Uuid::parse_str(&request.operation_key).is_ok()); - assert!(keys.insert(request.operation_key)); - assert_eq!( - request.expected_content_digest, - ticket::ticket_content_digest(&snapshot) - ); - assert_eq!(request.expected_state, TicketWorkflowState::Ready); - assert_eq!(request.reason, "Resolved without repository changes"); - assert!(request.references.is_empty()); - assert!(request.author.is_none()); - } - } - - #[test] - fn legacy_ticket_adapters_surface_backend_authority_and_stale_state_rejections() { - for status in [403, 409] { - for close in [false, true] { - let client = decision_client(status, TicketWorkflowState::Ready); - let backend = WorkspaceHttpTicketBackend::new(client.clone()); - let result = if close { - backend.close(TicketIdOrSlug::from("T-718"), "Resolved".into()) - } else { - backend.set_workflow_state(TicketIdOrSlug::from("T-718"), legacy_change()) - }; - assert!( - result - .unwrap_err() - .to_string() - .contains(&format!("HTTP status {status}")) - ); - assert_eq!(client.requests.lock().unwrap().len(), 2); - } - } - } - - #[tokio::test] - async fn native_ticket_close_binds_subject_and_forwards_strong_closed_state_decision() { - let client = decision_client(200, TicketWorkflowState::Closed); - let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workspace_authoring()) - .unwrap(); - let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); - let close = descriptor - .operations - .iter() - .find(|op| op.name == "close") - .unwrap(); - assert!( - !close - .parameters - .iter() - .any(|p| p.name == "ticket" || p.name == "author" || p.name == "role") - ); - for field in [ - "operation_key", - "expected_content_digest", - "expected_state", - "resolution", - ] { - assert!( - close - .parameters - .iter() - .any(|p| p.name == field && p.required) - ); - } - assert!( - close - .parameters - .iter() - .any(|p| p.name == "references" && !p.required) - ); - let observations = Arc::new(Mutex::new(TicketObservationState::default())); - let resolver = TicketItemResolver { - tools: operation_map(tools), - permissions: None, - collection_route: "/tickets".into(), - observations, - }; - record_ticket_observation( - &resolver.observations, - Some("T-718"), - &json!({"ticket": "T-718", "body": "observed requirements"}), - false, - ); - let bound = resolver.resolve("T-718").unwrap(); - let before = bound.object.validator; - let other = resolver.resolve("T-719").unwrap().object.validator; - let mut input = close_input(); input.as_object_mut().unwrap().remove("ticket"); - let arguments = input - .as_object() - .unwrap() - .iter() - .map(|(key, value)| (key.clone(), json_to_wip(value).unwrap())) - .collect(); - bound - .handler - .call( - "close", - &arguments, - WipCallContext { - execution: ToolExecutionContext::direct(), - security_context: "author".into(), - }, - ) - .await - .unwrap_or_else(|_| panic!("bound close should succeed")); - assert_ne!(before, resolver.resolve("T-718").unwrap().object.validator); - assert_eq!(other, resolver.resolve("T-719").unwrap().object.validator); + input["author"] = json!("self-reported-orchestrator"); + backend + .update_case("T-739", serde_json::from_value(input.clone()).unwrap()) + .unwrap(); let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].path, - "/api/w/workspace/tickets/T-718/state-update" - ); + assert_eq!(requests.len(), 2); + assert_eq!(requests[1].path, "/api/w/workspace/tickets/T-739/record"); + assert_eq!(requests[0].path, "/workspaces/workspace/tickets/T-739/case"); let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); - assert_eq!( - body, - json!({ - "operation_key": "decision-1", "expected_content_digest": "content_digest-1", - "expected_state": "planning", "state": "closed", - "reason": "No repository changes are needed", "references": [] - }) - ); + input.as_object_mut().unwrap().remove("author"); + input["references"] = json!([]); + assert_eq!(body, input); } - #[tokio::test] - async fn native_ticket_close_preserves_backend_rejections_and_wip_permission_gate() { - for (status, code) in [ - (403, ProtocolErrorCode::PermissionDenied), - (409, ProtocolErrorCode::InvalidArguments), - ] { - let client = decision_client(status, TicketWorkflowState::Planning); - let tools = - native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); - let tool = tools - .iter() - .find(|tool| tool.name == "TicketClose") - .unwrap(); - let error = execute_native_ticket_tool( - tool, - &None, - close_input(), - WipCallContext { - execution: ToolExecutionContext::direct(), - security_context: "self-reported-author".into(), - }, - ) - .await - .expect_err("Backend rejection must be preserved"); - assert!( - matches!(error, WipOperationError::Protocol(ProtocolError { code: actual, .. }) if actual == code) - ); - assert_eq!(client.requests.lock().unwrap().len(), 1); - } - let client = decision_client(200, TicketWorkflowState::Closed); - let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); - let tool = tools - .iter() - .find(|tool| tool.name == "TicketClose") - .unwrap(); - let permissions = Some(ToolPermissionConfig { - default_action: ToolPermissionAction::Deny, - rules: Vec::new(), - }); - let error = execute_native_ticket_tool( - tool, - &permissions, - close_input(), - WipCallContext { - execution: ToolExecutionContext::direct(), - security_context: "author".into(), - }, - ) - .await - .expect_err("WIP permission denial must prevent dispatch"); - assert!(matches!( - error, - WipOperationError::Protocol(ProtocolError { - code: ProtocolErrorCode::PermissionDenied, - .. - }) - )); + #[test] + fn case_backend_rejects_creation_closed_and_untyped_state_change_without_dispatch() { + let client = decision_client(200, TicketState::Open); + let backend = WorkspaceHttpTicketBackend::new(client.clone()); + let mut input = NewTicket::new("No bypass"); + input.workflow_state = Some(TicketState::Closed); + assert!( + backend + .create(input) + .unwrap_err() + .to_string() + .contains("created open") + ); + let change = TicketStateChange::new("open", "closed", "Decision", "Explanation"); + assert!( + backend + .add_state_changed(TicketIdOrSlug::from("T-739"), change) + .is_err() + ); assert!(client.requests.lock().unwrap().is_empty()); } - #[tokio::test] - async fn completion_accepts_mrless_decisions_and_forwards_cas_without_actor_claims() { - let client = decision_client(200, TicketWorkflowState::Done); - let (_, tool) = workspace_ticket_decision_definition( - client.clone(), - WorkspaceTicketDecisionKind::Complete, - )(); - let output = tool - .execute( - &decision_input().to_string(), - ToolExecutionContext::direct(), - ) - .await - .unwrap(); - let projected: Value = serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); - assert_eq!(projected["meta"]["id"], "T-718"); - assert_eq!(projected["meta"]["workflow_state"], "done"); - let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!(requests[0].path, "/api/w/workspace/tickets/T-718/complete"); - assert_eq!(requests[0].method, WorkspaceRequestMethod::Post); - let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); - assert_eq!( - body, - json!({ - "operation_key": "decision-1", "expected_content_digest": "content_digest-1", - "expected_state": "planning", "reason": "No repository changes are needed", "references": [] - }) - ); - } - - #[tokio::test] - async fn state_updates_forward_each_display_state_without_other_control_operations() { + #[test] + fn query_schema_allows_only_open_and_closed_case_states() { + let client = decision_client(200, TicketState::Open); + let (meta, _) = workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Query)(); + let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); + assert!(validator.is_valid(&json!({"states":["open", "closed"]}))); for state in [ "planning", "ready", "queued", + "inprogress", "in_progress", "done", - "closed", ] { - let client = decision_client(200, TicketWorkflowState::Planning); - let (_, tool) = workspace_ticket_decision_definition( - client.clone(), - WorkspaceTicketDecisionKind::UpdateState, - )(); - let mut input = decision_input(); - input["state"] = json!(state); - tool.execute(&input.to_string(), ToolExecutionContext::direct()) - .await - .unwrap(); - let requests = client.requests.lock().unwrap(); - assert_eq!(requests.len(), 1); - assert_eq!( - requests[0].path, - "/api/w/workspace/tickets/T-718/state-update" + assert!(!validator.is_valid(&json!({"states":[state]}))); + assert!( + serde_json::from_value::(json!({"states":[state]})) + .is_err() ); - let mut expected = input.as_object().unwrap().clone(); - expected.remove("ticket"); - expected.insert("references".into(), json!([])); - let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); - assert_eq!(body, Value::Object(expected)); } } #[tokio::test] - async fn decision_inputs_require_reason_cas_and_reject_self_reported_authority_before_dispatch() - { - for kind in [ - WorkspaceTicketDecisionKind::Complete, - WorkspaceTicketDecisionKind::UpdateState, - WorkspaceTicketDecisionKind::Close, + async fn case_update_forwards_all_judgments_and_replays_without_actor_or_repo_requirements() { + for (action, state, closure_key) in [ + ( + json!({"action":"close", "reason":"completed"}), + "open", + Value::Null, + ), + ( + json!({"action":"close", "reason":"not_planned"}), + "open", + Value::Null, + ), + ( + json!({"action":"close", "reason":"duplicate"}), + "open", + Value::Null, + ), + ( + json!({"action":"close", "reason":"unknown"}), + "open", + Value::Null, + ), + (json!({"action":"reopen"}), "closed", json!("closed-1")), + ( + json!({"action":"correct_closure", "reason":{"other":"Changed judgment"}}), + "closed", + json!("closed-1"), + ), ] { - let client = decision_client(200, TicketWorkflowState::Done); - let (meta, tool) = workspace_ticket_decision_definition(client.clone(), kind)(); - let mut valid = decision_input(); - if matches!(kind, WorkspaceTicketDecisionKind::UpdateState) { - valid["state"] = json!("ready"); + let client = decision_client(200, TicketState::Closed); + let (meta, tool) = workspace_ticket_decision_definition( + client.clone(), + WorkspaceTicketDecisionKind::CaseUpdate, + )(); + assert_eq!(meta.name, "TicketCaseUpdate"); + let mut input = decision_input(); + input["action"] = action; + input["expected_state"] = json!(state); + input["expected_closure_operation_key"] = closure_key; + input["references"] = json!([{"kind":"result", "target":"notes"}]); + assert!( + jsonschema::validator_for(&meta.input_schema) + .unwrap() + .is_valid(&input) + ); + for _ in 0..2 { + let output = tool + .execute(&input.to_string(), ToolExecutionContext::direct()) + .await + .unwrap(); + let projected: Value = + serde_json::from_str(output.content.as_deref().unwrap()).unwrap(); + assert_eq!(projected["meta"]["workflow_state"], "closed"); } - let reason_field = if matches!(kind, WorkspaceTicketDecisionKind::Close) { - let reason = valid.as_object_mut().unwrap().remove("reason").unwrap(); - valid["resolution"] = reason; - "resolution" - } else { - "reason" - }; - let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); - assert!(validator.is_valid(&valid)); - for field in [ - "operation_key", - "expected_content_digest", - "expected_state", - reason_field, - ] { - let mut invalid = valid.clone(); - invalid.as_object_mut().unwrap().remove(field); - assert!(!validator.is_valid(&invalid)); - assert!( - tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) - .await - .is_err() - ); - } - for field in [ - reason_field, - "operation_key", - "expected_content_digest", - "ticket", - ] { - let mut invalid = valid.clone(); - invalid[field] = json!(" "); - assert!( - tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) - .await - .is_err() - ); - } - for field in [ - "role", - "author", - "merge_request_ids", - "requirement_approval_event_id", - ] { - let mut invalid = valid.clone(); - invalid[field] = json!("coder"); - assert!(!validator.is_valid(&invalid)); - assert!( - tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) - .await - .is_err() - ); + let mut expected = input.as_object().unwrap().clone(); + expected.remove("ticket"); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 4); + assert_eq!( + requests + .iter() + .filter(|r| r.method == WorkspaceRequestMethod::Get) + .count(), + 2 + ); + for request in requests + .iter() + .filter(|r| r.method == WorkspaceRequestMethod::Post) + { + assert_eq!(request.path, "/workspaces/workspace/tickets/T-739/case"); + assert_eq!(request.method, WorkspaceRequestMethod::Post); + let body: Value = serde_json::from_str(request.body.as_deref().unwrap()).unwrap(); + assert_eq!(body, Value::Object(expected.clone())); } + } + } + + #[tokio::test] + async fn case_update_requires_observed_nullable_closure_cas_and_rejects_malformed_or_actor_claims() + { + let client = decision_client(200, TicketState::Closed); + let (meta, tool) = workspace_ticket_decision_definition( + client.clone(), + WorkspaceTicketDecisionKind::CaseUpdate, + )(); + let validator = jsonschema::validator_for(&meta.input_schema).unwrap(); + let valid = decision_input(); + assert!(validator.is_valid(&valid)); + for field in [ + "ticket", + "operation_key", + "expected_content_digest", + "expected_state", + "expected_closure_operation_key", + "action", + "explanation", + ] { let mut invalid = valid.clone(); - invalid["expected_state"] = json!("approved"); - assert!(!validator.is_valid(&invalid)); + invalid.as_object_mut().unwrap().remove(field); + assert!( + !validator.is_valid(&invalid), + "required schema field {field}" + ); + assert!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await + .is_err(), + "required decode field {field}" + ); + } + for (field, value) in [ + ("expected_state", json!("done")), + ("expected_state", json!("queued")), + ("expected_closure_operation_key", json!(42)), + ("action", json!({"action":"close"})), + ("action", json!({"action":"close", "reason":"success"})), + ("action", json!({"action":"reopen", "reason":"completed"})), + ("actor", json!("orchestrator")), + ("author", json!("coder")), + ("role", json!("coder")), + ("merge_request_ids", json!([])), + ] { + let mut invalid = valid.clone(); + invalid[field] = value; + assert!(!validator.is_valid(&invalid), "schema accepts {invalid}"); + assert!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await + .is_err(), + "decoder accepts {invalid}" + ); + } + for field in [ + "ticket", + "operation_key", + "expected_content_digest", + "explanation", + "expected_closure_operation_key", + ] { + let mut invalid = valid.clone(); + invalid[field] = json!(" "); assert!( tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) .await .is_err() ); - assert!(client.requests.lock().unwrap().is_empty()); } + let mut invalid = valid; + invalid["action"] = json!({"action":"close", "reason":{"other":" "}}); + assert!( + tool.execute(&invalid.to_string(), ToolExecutionContext::direct()) + .await + .is_err() + ); + assert!(client.requests.lock().unwrap().is_empty()); } #[tokio::test] - async fn decision_tools_preserve_backend_assignment_and_cas_rejections() { + async fn case_update_preserves_backend_cas_authority_rejections_and_wip_permission_gate() { for (status, code) in [ (403, ProtocolErrorCode::PermissionDenied), (409, ProtocolErrorCode::InvalidArguments), ] { - let client = decision_client(status, TicketWorkflowState::Planning); + let client = decision_client(status, TicketState::Open); let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap(); let tool = tools .iter() - .find(|tool| tool.name == "CompleteTicket") + .find(|tool| tool.name == "TicketCaseUpdate") .unwrap(); let error = execute_native_ticket_tool( tool, @@ -2934,70 +2428,89 @@ mod tests { ); assert_eq!(client.requests.lock().unwrap().len(), 1); } - } - - #[test] - fn native_ticket_decision_descriptions_preserve_intent_and_independent_lifecycle() { - let tools = native_ticket_tools( - decision_client(200, TicketWorkflowState::Ready), - TicketFeatureAccess::work_report(), - ) - .unwrap(); - let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); - let complete = descriptor - .operations + let client = decision_client(200, TicketState::Closed); + let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); + let tool = tools .iter() - .find(|op| op.name == "complete") + .find(|tool| tool.name == "TicketCaseUpdate") .unwrap(); - let summary = &complete.documentation.as_ref().unwrap().summary; - assert!(summary.contains("Merge Requests and approvals are not prerequisites")); - assert!(summary.contains("return without conclusion need not call this tool")); - assert!(summary.contains("not real judgment")); - assert!(summary.contains("retaining terminal responsibility")); - assert!(summary.contains("does not grant review/merge authority or stop/remove Workers")); + let permissions = Some(ToolPermissionConfig { + default_action: ToolPermissionAction::Deny, + rules: Vec::new(), + }); + let error = execute_native_ticket_tool( + tool, + &permissions, + decision_input(), + WipCallContext { + execution: ToolExecutionContext::direct(), + security_context: "author".into(), + }, + ) + .await + .expect_err("WIP denial prevents dispatch"); + assert!(matches!( + error, + WipOperationError::Protocol(ProtocolError { + code: ProtocolErrorCode::PermissionDenied, + .. + }) + )); + assert!(client.requests.lock().unwrap().is_empty()); } #[tokio::test] - async fn native_ticket_completion_binds_subject_and_stales_only_its_ticket_not_mrs() { - let client = decision_client(200, TicketWorkflowState::Done); - let observations = Arc::new(Mutex::new(TicketObservationState::default())); + async fn native_case_update_binds_subject_requires_closure_observation_and_stales_only_its_ticket() + { + let client = decision_client(200, TicketState::Closed); let tools = native_ticket_tools(client.clone(), TicketFeatureAccess::work_report()).unwrap(); let descriptor = ticket_descriptor(&tools, "Ticket", "bound").unwrap(); - let complete = descriptor + let case_update = descriptor .operations .iter() - .find(|op| op.name == "complete") + .find(|op| op.name == "case_update") .unwrap(); - assert!(!complete.parameters.iter().any(|p| p.name == "ticket")); + assert!( + !case_update + .parameters + .iter() + .any(|p| p.name == "ticket" || p.name == "actor" || p.name == "author") + ); for field in [ "operation_key", "expected_content_digest", "expected_state", - "reason", + "expected_closure_operation_key", + "action", + "explanation", ] { assert!( - complete + case_update .parameters .iter() .any(|p| p.name == field && p.required) ); } + let summary = &case_update.documentation.as_ref().unwrap().summary; + assert!(summary.contains("Closed does not imply success")); + assert!(summary.contains("not real judgment")); + assert!(summary.contains("Return without conclusion need not call this tool")); let resolver = TicketItemResolver { tools: operation_map(tools), permissions: None, collection_route: "/tickets".into(), - observations: observations.clone(), + observations: Arc::new(Mutex::new(TicketObservationState::default())), }; record_ticket_observation( &resolver.observations, - Some("T-718"), - &json!({"ticket": "T-718", "body": "observed requirements"}), + Some("T-739"), + &json!({"ticket":"T-739", "body":"observed requirements"}), false, ); - let bound = resolver.resolve("T-718").unwrap(); + let bound = resolver.resolve("T-739").unwrap(); let before = bound.object.validator; - let other = resolver.resolve("T-719").unwrap().object.validator; + let other = resolver.resolve("T-740").unwrap().object.validator; let mut input = decision_input(); input.as_object_mut().unwrap().remove("ticket"); let arguments = input @@ -3009,7 +2522,7 @@ mod tests { bound .handler .call( - "complete", + "case_update", &arguments, WipCallContext { execution: ToolExecutionContext::direct(), @@ -3017,13 +2530,32 @@ mod tests { }, ) .await - .unwrap_or_else(|_| panic!("bound completion should succeed")); - assert_ne!(before, resolver.resolve("T-718").unwrap().object.validator); - assert_eq!(other, resolver.resolve("T-719").unwrap().object.validator); - assert_eq!( - client.requests.lock().unwrap()[0].path, - "/api/w/workspace/tickets/T-718/complete" - ); + .unwrap_or_else(|_| panic!("WIP case update rejected")); + assert_ne!(before, resolver.resolve("T-739").unwrap().object.validator); + assert_eq!(other, resolver.resolve("T-740").unwrap().object.validator); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert_eq!(requests[0].path, "/workspaces/workspace/tickets/T-739/case"); + assert_eq!(requests[1].path, "/api/w/workspace/tickets/T-739/record"); + let body: Value = serde_json::from_str(requests[0].body.as_deref().unwrap()).unwrap(); + let mut expected = input; + expected["references"] = json!([]); + assert_eq!(body, expected); + } + + #[tokio::test] + async fn case_update_never_falls_back_without_workspace_authority() { + let client = crate::worker::unavailable_workspace_client(None, "no Workspace configured"); + let (_, tool) = + workspace_ticket_decision_definition(client, WorkspaceTicketDecisionKind::CaseUpdate)(); + let error = tool + .execute( + &decision_input().to_string(), + ToolExecutionContext::direct(), + ) + .await + .unwrap_err(); + assert!(error.to_string().contains("Workspace identity")); } #[test] @@ -3048,11 +2580,9 @@ mod tests { "comment", "mark_ready", "queue", - "transition", - "close", + "case_update", "record_relation", "remove_relation", - "complete", ] ); let item = ticket_descriptor( @@ -3094,14 +2624,12 @@ mod tests { "query", "read", "comment", - "transition", - "close", + "case_update", "dependency_check", "record_relation", "remove_relation", "record_orchestration_plan", "query_orchestration_plans", - "complete", ] ); assert_eq!( @@ -3253,7 +2781,7 @@ mod tests { collection_route: "/tickets".into(), observations: Arc::clone(&observations), }; - let response = json!({"ticket": "T-42", "body": "actual content", "state": "planning"}); + let response = json!({"ticket": "T-42", "body": "actual content", "state": "open"}); assert!(resolver.resolve("T-42").unwrap().object.validator.is_none()); record_ticket_observation(&observations, Some("T-42"), &response, false); let observed = resolver.resolve("T-42").unwrap().object.validator; @@ -3332,7 +2860,7 @@ mod tests { )); assert!(matches!( map_ticket_tool_error(ToolError::Internal( - "TicketClose outcome unknown after mutation: HTTP status 404".into() + "TicketCaseUpdate outcome unknown after mutation: HTTP status 404".into() )), WipOperationError::OutcomeUnknown(_) )); @@ -3393,7 +2921,7 @@ mod tests { assert!(query.input_schema["properties"]["attention"].is_object()); assert!(query.input_schema["properties"]["cursor"].is_object()); let query_schema = serde_json::to_string(&query.input_schema).unwrap(); - assert!(query_schema.contains("done_not_closed")); + assert!(!query_schema.contains("done_not_closed")); assert!(query_schema.contains("request_changes")); assert!(query_schema.contains("created_desc")); assert!( @@ -3404,13 +2932,13 @@ mod tests { assert_eq!(show.name, "ShowTicket"); assert!(show.input_schema["properties"]["event_limit"].is_object()); let tool_names = TicketFeatureAccess::workspace_authoring().tool_names(); - assert_eq!(tool_names.len(), 12); + assert_eq!(tool_names, WORKSPACE_AUTHORING_TOOL_NAMES); assert!( tool_names.len() < 13, "authoring catalog must stay below the prior broad catalog" ); let workflow_names = TicketFeatureAccess::workflow().tool_names(); - assert_eq!(workflow_names.len(), 11); + assert_eq!(workflow_names, WORKFLOW_TOOL_NAMES); assert!( workflow_names.len() < 12, "workflow catalog must stay below the prior broad catalog" @@ -3482,7 +3010,7 @@ mod tests { assert!(workspace_tools.contains(&"TicketCreate")); assert!(workspace_tools.contains(&"TicketEditItem")); assert!(workspace_tools.contains(&"TicketQueue")); - assert!(workspace_tools.contains(&"TicketWorkflowState")); + assert!(workspace_tools.contains(&"TicketCaseUpdate")); let orchestration = workspace_feature(TicketFeatureAccess::workflow()); let orchestration_descriptor = orchestration.descriptor(); @@ -3491,7 +3019,7 @@ mod tests { .iter() .map(|tool| tool.name.as_str()) .collect::>(); - assert!(orchestration_tools.contains(&"TicketWorkflowState")); + assert!(orchestration_tools.contains(&"TicketCaseUpdate")); assert!(orchestration_tools.contains(&"TicketDependencyCheck")); assert!(orchestration_tools.contains(&"TicketRelationRecord")); assert!(orchestration_tools.contains(&"TicketOrchestrationPlanRecord")); @@ -3506,8 +3034,7 @@ mod tests { .map(|tool| tool.name.as_str()) .collect::>(); assert!(work_report_tools.contains(&"TicketComment")); - assert!(work_report_tools.contains(&"CompleteTicket")); - assert!(work_report_tools.contains(&"TicketWorkflowState")); + assert!(work_report_tools.contains(&"TicketCaseUpdate")); let review = workspace_feature(TicketFeatureAccess::review()); let review_descriptor = review.descriptor(); @@ -3516,8 +3043,7 @@ mod tests { .iter() .map(|tool| tool.name.as_str()) .collect::>(); - assert!(!review_tools.contains(&"TicketWorkflowState")); - assert!(!review_tools.contains(&"CompleteTicket")); + assert!(!review_tools.contains(&"TicketCaseUpdate")); } #[test] @@ -3566,7 +3092,7 @@ mod tests { assert!(installed.iter().any(|tool| *tool == "TicketQueue")); assert!(installed.iter().any(|tool| *tool == "TicketMarkReady")); assert!(!installed.iter().any(|tool| *tool == "TicketIntakeReady")); - assert!(installed.iter().any(|tool| *tool == "TicketWorkflowState")); + assert!(installed.iter().any(|tool| *tool == "TicketCaseUpdate")); assert!( !installed .iter() @@ -3623,7 +3149,7 @@ mod tests { let error = backend .invoke(TicketBackendOperation::DefaultIntakeReadyStateChangeBody { - from: "planning".to_string(), + from: "open".to_string(), }) .unwrap_err(); @@ -3813,7 +3339,7 @@ mod tests { let created = sqlite.create(NewTicket::new("Ticket handoff")).unwrap(); let mut ticket = sqlite.show(TicketIdOrSlug::Id(created.id.clone())).unwrap(); ticket.meta.resource_key = Some("T-548".to_string()); - ticket.meta.workflow_state = TicketWorkflowState::Queued; + ticket.meta.workflow_state = TicketState::Open; let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let base_url = format!("http://{}", listener.local_addr().unwrap()); @@ -3843,7 +3369,7 @@ mod tests { server.join().unwrap(); assert_eq!(handoff.id, created.id); assert_eq!(handoff.resource_key, "T-548"); - assert_eq!(handoff.workflow_state, TicketWorkflowState::Queued); + assert_eq!(handoff.workflow_state, TicketState::Open); } #[test] diff --git a/crates/worker/src/prompt/catalog.rs b/crates/worker/src/prompt/catalog.rs index a1901a27d..cdb30cfae 100644 --- a/crates/worker/src/prompt/catalog.rs +++ b/crates/worker/src/prompt/catalog.rs @@ -796,7 +796,7 @@ mod tests { "ReviewMergeRequest", "CheckMergeRequestReadiness", "CompleteMergeRequest", - "CompleteTicket", + "TicketCaseUpdate", ] { assert!(combined.contains(operation), "missing {operation}"); } diff --git a/crates/worker/src/prompt/system.rs b/crates/worker/src/prompt/system.rs index 4fbff3406..294cd98d1 100644 --- a/crates/worker/src/prompt/system.rs +++ b/crates/worker/src/prompt/system.rs @@ -384,12 +384,12 @@ mod tests { assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest")); let complete = tools .iter() - .find(|meta| meta.name == "CompleteTicket") + .find(|meta| meta.name == "TicketCaseUpdate") .unwrap(); assert!( complete .description - .contains("Merge Requests and approvals are not prerequisites") + .contains("Repository, Merge Request and approval evidence are not prerequisites") ); assert!(complete.description.contains("not real judgment")); assert!( @@ -461,12 +461,12 @@ mod tests { ); let complete = tools .iter() - .find(|meta| meta.name == "CompleteTicket") + .find(|meta| meta.name == "TicketCaseUpdate") .unwrap(); assert!( complete .description - .contains("return without conclusion need not call this tool") + .contains("Return without conclusion need not call this tool") ); } } diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 8f8001af2..8c0b176c5 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -7,11 +7,11 @@ use merge_request::{ MergeRequest, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, ReviewDecision, }; use project_record::{allocate_record_id, unix_epoch_millis_now}; -use rusqlite::{params_from_iter, types::Value as SqlValue}; +use rusqlite::{OptionalExtension, params, params_from_iter, types::Value as SqlValue}; use ticket::{ SqliteTicketBackend, SqliteTicketListCursor, SqliteTicketListItem, SqliteTicketListPageQuery, - TicketBackend, TicketEvent, TicketIdOrSlug, TicketWorkflowState, TicketWorkspaceActionPriority, + TicketBackend, TicketEvent, TicketIdOrSlug, TicketState, TicketWorkspaceActionPriority, project_ticket_workspace_item, }; @@ -282,7 +282,7 @@ impl SqliteWorkspaceAuthority { id: ticket.id, resource_key: ticket.resource_key, title: ticket.title, - state: ticket.state, + state: ticket.state.as_str().into(), }) .collect::>(); let resources = self @@ -387,7 +387,7 @@ impl SqliteWorkspaceAuthority { let states = query .states .iter() - .map(|state| bind(SqlValue::Text(state.clone()))) + .map(|state| bind(SqlValue::Text(state.as_str().into()))) .collect::>(); predicates.push(format!("t.workflow_state IN ({})", states.join(","))); } @@ -438,14 +438,7 @@ impl SqliteWorkspaceAuthority { WHERE relation.workspace_id=t.workspace_id AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on') OR (relation.target=t.ticket_id AND relation.kind='blocks')) - AND blocker.workflow_state NOT IN ('done','closed'))"; - let active_blocker = "EXISTS (SELECT 1 FROM typed_ticket_relations relation - JOIN typed_tickets blocker ON blocker.workspace_id=relation.workspace_id - AND blocker.ticket_id=CASE WHEN relation.ticket_id=t.ticket_id THEN relation.target ELSE relation.ticket_id END - WHERE relation.workspace_id=t.workspace_id - AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on') - OR (relation.target=t.ticket_id AND relation.kind='blocks')) - AND blocker.workflow_state NOT IN ('queued','inprogress','done','closed'))"; + AND blocker.workflow_state = 'open')"; if !query.event_kinds.is_empty() { let event_kinds = query .event_kinds @@ -459,17 +452,13 @@ impl SqliteWorkspaceAuthority { // a primary-MR/latest-request or implementation-report heuristic loses candidates. for attention in &query.attention { match attention.as_str() { - "done_not_closed" => predicates.push("t.workflow_state='done'".into()), "blocked" => predicates.push(blocker.into()), "unblocked" => predicates.push(format!("NOT {blocker}")), - "ready" => predicates.push(format!("t.workflow_state='ready' AND NOT {blocker}")), _ => {} // live evidence is evaluated after observation } } let rank_expression = match sort { - TicketQuerySort::Priority => format!( - "CASE WHEN t.workflow_state='ready' AND NOT {active_blocker} THEN 0 WHEN t.workflow_state IN ('queued','inprogress') THEN 1 ELSE 2 END" - ), + TicketQuerySort::Priority => "0".to_string(), TicketQuerySort::Relevance => { if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) { let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase()))); @@ -807,24 +796,13 @@ impl SqliteWorkspaceAuthority { } let action_blockers = Vec::new(); let action_eligibility = TicketActionEligibility { - can_assign_orchestrator: matches!( - ticket.meta.workflow_state, - TicketWorkflowState::Planning | TicketWorkflowState::Ready - ) && !has_orchestrator - && !has_worker, - can_unassign_orchestrator: has_orchestrator - && matches!( - ticket.meta.workflow_state, - TicketWorkflowState::Planning | TicketWorkflowState::Ready - ), - can_queue: true, - can_start_manual_worker: matches!( - ticket.meta.workflow_state, - TicketWorkflowState::Planning - | TicketWorkflowState::Ready - | TicketWorkflowState::Queued - | TicketWorkflowState::InProgress - ) && !has_worker + // Case openness is not readiness. These are explicit operations; + // occupancy remains an independent resource constraint. + can_assign_orchestrator: !has_orchestrator && !has_worker, + can_unassign_orchestrator: has_orchestrator, + can_queue: ticket.meta.workflow_state == TicketState::Open, + can_start_manual_worker: ticket.meta.workflow_state == TicketState::Open + && !has_worker && !has_blockers, queue_tickets: dependency_check.queue_tickets.clone(), blockers: action_blockers, @@ -916,12 +894,16 @@ impl SqliteWorkspaceAuthority { id: ticket.meta.id, resource_key, title: ticket.meta.title, - state: ticket.meta.workflow_state.as_str().to_string(), + state: ticket.meta.workflow_state.into(), readiness: ticket.meta.readiness, priority: ticket.meta.priority, created_at: ticket.meta.created_at, updated_at: ticket.meta.updated_at, content_digest, + closure_operation_key: ticket.closure_operation_key.clone(), + current_closure: ticket.current_closure.clone(), + closure_history: ticket.case_history.clone(), + legacy_handoff: ticket.legacy_workflow_snapshot.clone(), queued_by: ticket.meta.queued_by, queued_at: ticket.meta.queued_at, targets, @@ -963,6 +945,44 @@ impl SqliteWorkspaceAuthority { } } +impl SqliteWorkspaceAuthority { + /// Bounded list projection: read only one immutable closure record and the + /// migration handoff, never the full body or event history for every row. + fn populate_ticket_summary_case_fields(&self, summary: &mut TicketSummary) -> Result<()> { + self.store.with_conn(|conn| { + summary.closure_operation_key = conn.query_row( + "SELECT closure_operation_key FROM typed_ticket_case_metadata WHERE workspace_id=?1 AND ticket_id=?2", + params![self.workspace_id, summary.id], |row| row.get(0) + ).optional()?.flatten(); + if summary.state == server_api::BrowserTicketState::Closed { + if let Some(key) = &summary.closure_operation_key { + let json: Option = conn.query_row( + "SELECT judgment_json FROM typed_ticket_case_history WHERE workspace_id=?1 AND ticket_id=?2 AND operation_key=?3", + params![self.workspace_id, summary.id, key], |row| row.get(0) + ).optional()?; + if let Some(json) = json { + let entry: ticket::TicketCaseHistoryEntry = serde_json::from_str(&json).map_err(|e| Error::Store(e.to_string()))?; + let reason = match entry.action { + ticket::TicketCaseAction::Close { reason } | ticket::TicketCaseAction::CorrectClosure { reason } => reason, + ticket::TicketCaseAction::Reopen => return Err(Error::Store("Closure key names a reopen receipt".into())), + }; + summary.current_closure = Some(ticket::TicketClosureJudgment { + operation_key: entry.operation_key, reason, explanation: entry.explanation, + references: entry.references, author: entry.author, at: entry.at, + }); + } + } + } + let snapshot: Option = conn.query_row( + "SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot WHERE workspace_id=?1 AND ticket_id=?2", + params![self.workspace_id, summary.id], |row| row.get(0) + ).optional()?; + summary.legacy_handoff = snapshot.map(|json| serde_json::from_str(&json).map_err(|e| Error::Store(e.to_string()))).transpose()?; + Ok(()) + }) + } +} + impl TicketAuthority for SqliteWorkspaceAuthority { fn list_tickets(&self, limit: usize) -> Result> { let projection = self.ticket_backend.list_workspace_projection(limit)?; @@ -970,24 +990,9 @@ impl TicketAuthority for SqliteWorkspaceAuthority { .items .into_iter() .map(|item| { - let projection = - project_ticket_workspace_item(&item.summary, &item.relation_blockers, None); - let resource_key = item.summary.resource_key.clone().ok_or_else(|| { - Error::Store(format!("missing resource key for {}", item.summary.id)) - })?; - Ok::<_, Error>(TicketSummary { - resource_key, - id: item.summary.id, - title: item.summary.title, - state: item.summary.workflow_state.as_str().to_string(), - priority: item.summary.priority, - updated_at: item.summary.updated_at, - queued_by: item.summary.queued_by, - queued_at: item.summary.queued_at, - workspace_action_priority: workspace_action_priority_name(projection.priority) - .to_string(), - record_source: "sqlite_yoi_ticket".to_string(), - }) + let mut summary = ticket_summary_from_sqlite_item(item)?; + self.populate_ticket_summary_case_fields(&mut summary)?; + Ok(summary) }) .collect::>>()?; Ok(ProjectRecordList { @@ -1005,7 +1010,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority { let parsed_states = states .iter() .map(|state| { - TicketWorkflowState::parse(state).ok_or_else(|| { + TicketState::parse(state).ok_or_else(|| { Error::InvalidRecordId(format!("unsupported ticket state `{state}`")) }) }) @@ -1029,7 +1034,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority { let items = page .items .into_iter() - .map(ticket_summary_from_sqlite_item) + .map(|item| { + let mut summary = ticket_summary_from_sqlite_item(item)?; + self.populate_ticket_summary_case_fields(&mut summary)?; + Ok(summary) + }) .collect::>>()?; let next_cursor = page .next @@ -1945,13 +1954,6 @@ fn ticket_evidence_summary( } fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { - for state in &query.states { - if ticket::TicketWorkflowState::parse(state).is_none() { - return Err(Error::InvalidRecordId(format!( - "unsupported Ticket workflow state `{state}`" - ))); - } - } for evidence in &query.evidence { if !matches!( evidence.as_str(), @@ -1962,25 +1964,6 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> { ))); } } - for attention in &query.attention { - if !matches!( - attention.as_str(), - "done_not_closed" - | "unresolved_review" - | "missing_commit" - | "blocked" - | "unblocked" - | "ready" - | "awaiting_review" - | "unresolved_changes" - | "stale_after_rescope" - | "missing_evidence" - ) { - return Err(Error::InvalidRecordId(format!( - "unsupported Ticket attention filter `{attention}`" - ))); - } - } if let Some(status) = query.review_status.as_deref() && !matches!( status, @@ -2125,19 +2108,17 @@ fn ticket_evidence_matches(evidence: &TicketEvidenceSummary, filter: &str) -> bo } fn ticket_attention_matches( - state: &str, + _state: &str, evidence: &TicketEvidenceSummary, is_blocked: bool, authoritative_events: &[TicketEvent], attention: &str, ) -> bool { match attention { - "done_not_closed" => state == "done", "unresolved_review" => evidence.unresolved_request_changes, "missing_commit" => !evidence.has_commit, "blocked" => is_blocked, "unblocked" => !is_blocked, - "ready" => state == "ready" && !is_blocked, "awaiting_review" => { evidence.has_review_request && evidence.review_status.as_deref() == Some("pending") } @@ -2209,11 +2190,11 @@ fn ticket_matches_query( } if !query.attention.iter().all(|attention| { ticket_attention_matches( - &summary.state, + summary.state.as_str(), &detail.evidence, !detail.relations.blockers.is_empty(), authoritative_events, - attention, + attention.as_str(), ) }) { return false; @@ -2323,6 +2304,9 @@ fn ticket_query_item( created_at: detail.created_at.clone(), updated_at: summary.updated_at, content_digest: detail.content_digest.clone(), + closure_operation_key: detail.closure_operation_key.clone(), + current_closure: detail.current_closure.clone(), + legacy_handoff: detail.legacy_handoff.clone(), workspace_action_priority: summary.workspace_action_priority, matched_fields, snippet: snippet.map(|value| truncate_body(&value, 512).0), @@ -2682,10 +2666,14 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result queued_at: ticket.meta.queued_at.clone(), updated_at: ticket.meta.updated_at.clone(), }; - ticket_summary_from_sqlite_item(SqliteTicketListItem { + let mut summary = ticket_summary_from_sqlite_item(SqliteTicketListItem { summary, relation_blockers: ticket.relations.blockers.clone(), - }) + })?; + summary.closure_operation_key = ticket.closure_operation_key.clone(); + summary.current_closure = ticket.current_closure.clone(); + summary.legacy_handoff = ticket.legacy_workflow_snapshot.clone(); + Ok(summary) } fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result { @@ -2699,13 +2687,16 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result SqliteWorkspaceStore { + fn reopened_terminal_assignment_store() -> SqliteWorkspaceStore { let store = setup(); seed_current_assignment(&store); store .with_conn(|conn| { conn.execute( - "UPDATE typed_tickets SET workflow_state=?1 + "UPDATE typed_tickets SET status='closed', workflow_state='closed' WHERE workspace_id='w' AND ticket_id='ticket'", - [terminal_state], + [], )?; conn.execute( - "UPDATE typed_tickets SET workflow_state='planning' + "UPDATE typed_tickets SET status='open', workflow_state='open' WHERE workspace_id='w' AND ticket_id='ticket'", [], )?; Ok(()) }) .unwrap(); + // Narrow lifecycle-schema evidence: closing durably releases work, + // while reopening retains routing without reviving that assignment. + let released: i64 = store + .with_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_work_releases + WHERE workspace_id='w' AND assignment_id='assignment'", + [], + |row| row.get(0), + ) + .map_err(StoreError::from) + }) + .unwrap(); + assert_eq!(released, 1); store } @@ -1646,103 +1660,93 @@ mod tests { #[test] fn terminal_retained_assignment_allows_worker_removal_even_after_reopen() { - for (status, workflow_state) in [("closed", "closed"), ("open", "done")] { - for reopen in [false, true] { - let store = setup(); - seed_current_assignment(&store); - store - .with_conn(|conn| { - conn.execute( - "UPDATE typed_tickets SET status=?1, workflow_state=?2 + for reopen in [false, true] { + let store = setup(); + seed_current_assignment(&store); + store + .with_conn(|conn| { + conn.execute( + "UPDATE typed_tickets SET status='closed', workflow_state='closed' WHERE workspace_id='w' AND ticket_id='ticket'", - params![status, workflow_state], - )?; - if reopen { - conn.execute( - "UPDATE typed_tickets SET status='open', workflow_state='planning' + [], + )?; + if reopen { + conn.execute( + "UPDATE typed_tickets SET status='open', workflow_state='open' WHERE workspace_id='w' AND ticket_id='ticket'", - [], - )?; - } - Ok(()) - }) - .unwrap(); - store - .update_worker_retention_policy( - "w", - &store - .worker_retention_policy("w") - .unwrap() - .unwrap() - .policy_digest, - &WorkerRetentionPolicyUpdate { - policy_id: "purge".into(), - session_disposition: SessionDisposition::Purge, - metadata_disposition: MetadataDisposition::Tombstone, - archive_retention: ArchiveRetention::Forever, - diagnostics_disposition: DiagnosticsDisposition::Purge, - diagnostics_retention_seconds: None, - }, - ) - .unwrap(); - let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); - assert!( - plan.blockers.is_empty(), - "{status}/{workflow_state}, reopened={reopen}" - ); - store - .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) - .unwrap(); - let result = WorkerRetentionExecutionResult { - operation_id: plan.operation_id.clone(), - input_fingerprint: plan.input_fingerprint.clone(), - worker_id: worker_id(), - session_disposition: plan.session_disposition, - diagnostics_disposition: plan.diagnostics_disposition, - archive: None, - source_removed: true, - diagnostics_retained: false, - }; - let committed = store - .commit_worker_removal( - "w", - &plan.operation_id, - &plan.input_fingerprint, - &result, - ) - .unwrap(); - assert_eq!(committed.plan.state, WorkerRemovalPlanState::Succeeded); - assert!( - store - .get_worker_registry("w", &plan.worker) + [], + )?; + } + Ok(()) + }) + .unwrap(); + store + .update_worker_retention_policy( + "w", + &store + .worker_retention_policy("w") .unwrap() - .is_none() - ); - assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_some()); - let (retained, historical): (i64, i64) = store - .with_conn(|conn| { - Ok(( - conn.query_row( - "SELECT COUNT(*) FROM ticket_current_worker_assignments + .unwrap() + .policy_digest, + &WorkerRetentionPolicyUpdate { + policy_id: "purge".into(), + session_disposition: SessionDisposition::Purge, + metadata_disposition: MetadataDisposition::Tombstone, + archive_retention: ArchiveRetention::Forever, + diagnostics_disposition: DiagnosticsDisposition::Purge, + diagnostics_retention_seconds: None, + }, + ) + .unwrap(); + let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); + assert!(plan.blockers.is_empty(), "closed case, reopened={reopen}"); + store + .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) + .unwrap(); + let result = WorkerRetentionExecutionResult { + operation_id: plan.operation_id.clone(), + input_fingerprint: plan.input_fingerprint.clone(), + worker_id: worker_id(), + session_disposition: plan.session_disposition, + diagnostics_disposition: plan.diagnostics_disposition, + archive: None, + source_removed: true, + diagnostics_retained: false, + }; + let committed = store + .commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result) + .unwrap(); + assert_eq!(committed.plan.state, WorkerRemovalPlanState::Succeeded); + assert!( + store + .get_worker_registry("w", &plan.worker) + .unwrap() + .is_none() + ); + assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_some()); + let (retained, historical): (i64, i64) = store + .with_conn(|conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM ticket_current_worker_assignments WHERE workspace_id='w' AND assignment_id='assignment'", - [], - |row| row.get(0), - )?, - conn.query_row( - "SELECT COUNT(*) FROM ticket_worker_assignments + [], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM ticket_worker_assignments WHERE workspace_id='w' AND assignment_id='assignment'", - [], - |row| row.get(0), - )?, - )) - }) - .unwrap(); - assert_eq!( - (retained, historical), - (1, 1), - "assignment history must survive removal" - ); - } + [], + |row| row.get(0), + )?, + )) + }) + .unwrap(); + assert_eq!( + (retained, historical), + (1, 1), + "assignment history must survive removal" + ); } } @@ -1768,42 +1772,40 @@ mod tests { #[test] fn distinct_assignment_after_terminal_end_blocks_preparation() { - for terminal_state in ["done", "closed"] { - let store = reopened_terminal_assignment_store(terminal_state); - let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); - assert!(plan.blockers.is_empty()); - install_current_assignment(&store, "new-assignment").unwrap(); - let blocked = store.plan_worker_removal(&req(), &inv()).unwrap(); - assert_eq!( - blocked.blockers, - vec![WorkerRemovalBlocker::UnfinishedWork { - assignment_id: "new-assignment".into(), - ticket_id: "ticket".into(), - }] - ); - let replay = store.plan_worker_removal(&req(), &inv()).unwrap(); - assert_eq!(replay.plan_id, blocked.plan_id); - assert_eq!(replay.input_fingerprint, blocked.input_fingerprint); - assert_ne!(blocked.plan_id, plan.plan_id); - let error = store - .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) - .unwrap_err(); - assert!( - matches!(error, WorkerRetentionError::StalePlan { reason, .. } + let store = reopened_terminal_assignment_store(); + let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); + assert!(plan.blockers.is_empty()); + install_current_assignment(&store, "new-assignment").unwrap(); + let blocked = store.plan_worker_removal(&req(), &inv()).unwrap(); + assert_eq!( + blocked.blockers, + vec![WorkerRemovalBlocker::UnfinishedWork { + assignment_id: "new-assignment".into(), + ticket_id: "ticket".into(), + }] + ); + let replay = store.plan_worker_removal(&req(), &inv()).unwrap(); + assert_eq!(replay.plan_id, blocked.plan_id); + assert_eq!(replay.input_fingerprint, blocked.input_fingerprint); + assert_ne!(blocked.plan_id, plan.plan_id); + let error = store + .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) + .unwrap_err(); + assert!( + matches!(error, WorkerRetentionError::StalePlan { reason, .. } if reason == "unfinished work added") - ); - assert!( - store - .get_worker_registry("w", &plan.worker) - .unwrap() - .is_some() - ); - } + ); + assert!( + store + .get_worker_registry("w", &plan.worker) + .unwrap() + .is_some() + ); } #[test] fn prepared_removal_fences_distinct_assignment_after_terminal_end() { - let store = reopened_terminal_assignment_store("done"); + let store = reopened_terminal_assignment_store(); let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); store .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) @@ -1837,7 +1839,7 @@ mod tests { #[test] fn commit_rechecks_distinct_assignment_independently_of_admission_fence() { - let store = reopened_terminal_assignment_store("done"); + let store = reopened_terminal_assignment_store(); let plan = store.plan_worker_removal(&req(), &inv()).unwrap(); store .prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint) @@ -1938,7 +1940,7 @@ mod tests { store .with_conn(|conn| { conn.execute( - "UPDATE typed_tickets SET workflow_state='done' + "UPDATE typed_tickets SET status='closed', workflow_state='closed' WHERE workspace_id='w' AND ticket_id='ticket'", [], )?; diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 339facd0e..ed9350433 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -99,8 +99,8 @@ use server_api::{TICKET_ORCHESTRATION_PLANS_QUERY_PATH, TICKET_RELATIONS_QUERY_P use sha2::{Digest, Sha256}; use ticket::{ MarkdownText, NewTicketEvent, TicketBackend, TicketBodyReplacement, TicketEventKind, - TicketIdOrSlug, TicketItemEdit, TicketStateChange, TicketTargetAccess, TicketTargetsEdit, - TicketWorkflowState, + TicketIdOrSlug, TicketItemEdit, TicketState, TicketStateChange, TicketTargetAccess, + TicketTargetsEdit, }; use ticket::{ SqliteTicketBackend, TicketBackendOperation, TicketBackendOperationResult, @@ -6551,21 +6551,9 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::ticket_thread_event_add( service.clone(), )) - .merge(server_api::server_api_axum::ticket_state_change_add( - service.clone(), - )) .merge(server_api::server_api_axum::ticket_intake_summary_add( service.clone(), )) - .merge(server_api::server_api_axum::ticket_state_field_set( - service.clone(), - )) - .merge(server_api::server_api_axum::ticket_workflow_state_set( - service.clone(), - )) - .merge(server_api::server_api_axum::ticket_state_update( - service.clone(), - )) .merge(server_api::server_api_axum::ticket_mark_ready_record( service.clone(), )) @@ -6603,10 +6591,7 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::merge_request_complete( service.clone(), )) - .merge(server_api::server_api_axum::ticket_complete( - service.clone(), - )) - .merge(server_api::server_api_axum::ticket_close_record( + .merge(server_api::server_api_axum::ticket_case_update( service.clone(), )) .merge(server_api::server_api_axum::ticket_relation_view( @@ -6634,15 +6619,11 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::ticket_implementation_cancel( service.clone(), )) - .merge(server_api::server_api_axum::ticket_state_transition( - service.clone(), - )) .merge(server_api::server_api_axum::ticket_ready(service.clone())) .merge(server_api::server_api_axum::ticket_event_append( service.clone(), )) .merge(server_api::server_api_axum::ticket_queue(service.clone())) - .merge(server_api::server_api_axum::ticket_close(service.clone())) .merge(server_api::server_api_axum::workspace_worker_list_alias( service.clone(), )) @@ -10055,24 +10036,6 @@ impl server_api::ServerApi for ServerApiContractService { Ok(()) } - async fn ticket_state_change_add( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::TicketStateChangeRequest, - ) -> std::result::Result<(), server_api::RepositoryApiError> { - scoped_add_ticket_state_change( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(()) - } - async fn ticket_intake_summary_add( &self, context: server_api::ServerRequestContext, @@ -10091,43 +10054,6 @@ impl server_api::ServerApi for ServerApiContractService { Ok(()) } - async fn ticket_state_field_set( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - field: String, - request: server_api::TicketStateChangeRequest, - ) -> std::result::Result<(), server_api::RepositoryApiError> { - scoped_set_ticket_state_field( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id, field)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(()) - } - - async fn ticket_workflow_state_set( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::TicketStateChangeRequest, - ) -> std::result::Result<(), server_api::RepositoryApiError> { - scoped_set_ticket_workflow_state( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(()) - } - async fn ticket_mark_ready_record( &self, context: server_api::ServerRequestContext, @@ -10356,58 +10282,47 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } - async fn ticket_complete( + async fn ticket_case_update( &self, context: server_api::ServerRequestContext, workspace_id: String, - id: String, - request: server_api::CompleteTicketRequest, - ) -> std::result::Result { - let Json(response) = scoped_complete_ticket( + ticket_id: String, + request: server_api::TicketCaseUpdateRequest, + ) -> std::result::Result { + let Json(updated) = scoped_ticket_case_update( State(self.workspace_api()?.clone()), contract_request_headers(&context)?, - AxumPath((workspace_id, id)), - Json(request), + AxumPath((workspace_id, ticket_id)), + Json(ticket::TicketCaseUpdate { + expected_content_digest: request.expected_content_digest, + expected_state: request.expected_state, + expected_closure_operation_key: request.expected_closure_operation_key, + operation_key: request.operation_key, + action: request.action, + explanation: request.explanation, + references: request.references, + author: None, + }), ) .await .map_err(ApiError::into_repository_api_error)?; - Ok(server_api::TicketRecord(response)) - } - - async fn ticket_state_update( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::TicketStateUpdateRequest, - ) -> std::result::Result { - let Json(response) = scoped_ticket_state_update( - State(self.workspace_api()?.clone()), - contract_request_headers(&context)?, - AxumPath((workspace_id, id)), - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(server_api::TicketRecord(response)) - } - - async fn ticket_close_record( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::TicketCloseRecordRequest, - ) -> std::result::Result<(), server_api::RepositoryApiError> { - scoped_close_ticket_record( - State(self.workspace_api()?.clone()), - AxumPath((workspace_id, id)), - contract_request_headers(&context)?, - Json(request.0), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(()) + let meta = updated.meta; + Ok(server_api::TicketSummary(ticket::TicketSummary { + id: meta.id, + resource_key: meta.resource_key, + slug: meta.slug, + title: meta.title, + status: meta.status, + kind: meta.kind, + priority: meta.priority, + labels: meta.labels, + readiness: meta.readiness, + workflow_state: meta.workflow_state, + workflow_state_explicit: meta.workflow_state_explicit, + queued_by: meta.queued_by, + queued_at: meta.queued_at, + updated_at: meta.updated_at, + })) } async fn ticket_relation_view( @@ -10603,24 +10518,6 @@ impl server_api::ServerApi for ServerApiContractService { Ok(response) } - async fn ticket_state_transition( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::BrowserTransitionTicketStateRequest, - ) -> std::result::Result { - let Json(response) = scoped_transition_ticket_state( - State(self.workspace_api()?.clone()), - contract_request_headers(&context)?, - AxumPath(ScopedRecordPath { workspace_id, id }), - Json(request), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(response) - } - async fn ticket_ready( &self, context: server_api::ServerRequestContext, @@ -10678,24 +10575,6 @@ impl server_api::ServerApi for ServerApiContractService { Ok(server_api::TicketQueueResponse(response)) } - async fn ticket_close( - &self, - context: server_api::ServerRequestContext, - workspace_id: String, - id: String, - request: server_api::BrowserCloseTicketRequest, - ) -> std::result::Result { - let Json(response) = scoped_close_ticket( - State(self.workspace_api()?.clone()), - contract_request_headers(&context)?, - AxumPath(ScopedRecordPath { workspace_id, id }), - Json(request), - ) - .await - .map_err(ApiError::into_repository_api_error)?; - Ok(response) - } - async fn objective_list_alias( &self, query: server_api::ObjectiveListQuery, @@ -13721,17 +13600,6 @@ async fn scoped_set_ticket_assignment( }; let assignment = match role { TicketAssignmentRole::Orchestrator => { - if !matches!( - ticket.state.as_str(), - state if state == TicketWorkflowState::Planning.as_str() - || state == TicketWorkflowState::Ready.as_str() - ) { - return Err(Error::TicketAssignmentConflict(format!( - "Orchestrator assignment requires planning or ready Ticket; current state is {}", - ticket.state - )) - .into()); - } if api .store .get_active_ticket_role_assignment( @@ -13808,18 +13676,6 @@ async fn scoped_clear_ticket_assignment( .ok_or_else(|| { Error::TicketAssignmentConflict("unassign requires assignment_id".to_string()) })?; - if matches!( - ticket.state.as_str(), - state if state == TicketWorkflowState::Queued.as_str() - || state == TicketWorkflowState::InProgress.as_str() - ) { - return Err(Error::TicketAssignmentConflict(format!( - "cannot unassign role `{}` while Ticket is {}; rescope through a guarded lifecycle operation", - role.as_str(), - ticket.state - )) - .into()); - } let cleared = api.store.clear_current_ticket_role_assignment( &workspace_id, &ticket.id, @@ -13862,18 +13718,6 @@ async fn scoped_cancel_ticket_implementation( .into()); } - if !matches!( - ticket.state.as_str(), - state if state == TicketWorkflowState::InProgress.as_str() - || state == TicketWorkflowState::Ready.as_str() - ) { - return Err(Error::TicketAssignmentConflict(format!( - "implementation cancellation requires an inprogress Ticket; current state is {}", - ticket.state - )) - .into()); - } - let current = api.store.get_active_ticket_role_assignment( &path.workspace_id, &ticket.id, @@ -13956,10 +13800,11 @@ fn validate_ticket_assignment_state( assignment: &WorkerTicketAssignmentRequest, ) -> Result<()> { let ticket = api.authority.ticket(&assignment.ticket_id)?; - if matches!(ticket.state.as_str(), "done" | "closed") { + if ticket.state == server_api::BrowserTicketState::Closed { return Err(Error::TicketAssignmentConflict(format!( "Ticket {} is {}; reopen through an explicit lifecycle decision before starting work", - ticket.id, ticket.state + ticket.id, + ticket.state.as_str() ))); } let dependencies = @@ -14140,25 +13985,14 @@ fn accept_queued_ticket_after_worker_spawn( assignment: &crate::hosts::WorkerTicketAssignmentRequest, ) -> Result<()> { let ticket = api.authority.ticket(&assignment.ticket_id)?; - if ticket.state == TicketWorkflowState::InProgress.as_str() { - return Ok(()); - } - if !matches!(ticket.state.as_str(), "planning" | "ready" | "queued") { + if ticket.state != server_api::BrowserTicketState::Open { return Err(Error::TicketAssignmentConflict(format!( - "Ticket {} left queued state before Ticket Worker spawn acceptance; current state is {}", - ticket.id, ticket.state + "Ticket {} was closed before explicit Worker spawn acceptance", + ticket.id ))); } - let mut change = TicketStateChange::new( - ticket.state.as_str(), - TicketWorkflowState::InProgress.as_str(), - "Ticket Worker spawn, assignment, and initial input were durably accepted", - "", - ); - change.author = Some("workspace-orchestrator".to_string()); - browser_ticket_backend(api)? - .set_workflow_state(TicketIdOrSlug::Id(ticket.id), change) - .map_err(Error::from)?; + // Claim/assignment receipts own execution acceptance. Never manufacture a + // Ticket phase or revive a released identity from its retained assignee. Ok(()) } @@ -14321,49 +14155,6 @@ async fn scoped_edit_ticket_item( browser_ticket_detail(&api, &path.id) } -fn browser_ticket_workflow_state( - state: server_api::BrowserTicketWorkflowState, -) -> TicketWorkflowState { - match state { - server_api::BrowserTicketWorkflowState::Planning => TicketWorkflowState::Planning, - server_api::BrowserTicketWorkflowState::Ready => TicketWorkflowState::Ready, - server_api::BrowserTicketWorkflowState::Queued => TicketWorkflowState::Queued, - server_api::BrowserTicketWorkflowState::Inprogress => TicketWorkflowState::InProgress, - server_api::BrowserTicketWorkflowState::Done => TicketWorkflowState::Done, - server_api::BrowserTicketWorkflowState::Closed => TicketWorkflowState::Closed, - } -} - -async fn scoped_transition_ticket_state( - State(api): State, - headers: HeaderMap, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let state = browser_ticket_workflow_state(request.state); - let reason = match request.body.filter(|body| !body.is_empty()) { - Some(body) => format!("{}\n\n{body}", request.reason), - None => request.reason, - }; - let _ = scoped_ticket_state_update( - State(api.clone()), - headers, - AxumPath((path.workspace_id.clone(), path.id.clone())), - Json(ticket::TicketStateUpdate { - state, - operation_key: request.operation_key, - expected_content_digest: request.expected_content_digest, - expected_state: browser_ticket_workflow_state(request.expected_state), - reason, - references: Vec::new(), - author: Some("workspace-user".into()), - }), - ) - .await?; - browser_ticket_detail(&api, &path.id) -} - async fn scoped_append_ticket_event( State(api): State, AxumPath(path): AxumPath, @@ -14426,36 +14217,9 @@ async fn scoped_queue_ticket( }) } -async fn scoped_close_ticket( - State(api): State, - headers: HeaderMap, - AxumPath(path): AxumPath, - Json(request): Json, -) -> ApiResult> { - validate_workspace_scope(&api, &path.workspace_id)?; - let _ = scoped_ticket_state_update( - State(api.clone()), - headers, - AxumPath((path.workspace_id.clone(), path.id.clone())), - Json(ticket::TicketStateUpdate { - state: TicketWorkflowState::Closed, - operation_key: request.operation_key, - expected_content_digest: request.expected_content_digest, - expected_state: browser_ticket_workflow_state(request.expected_state), - reason: request.resolution, - references: Vec::new(), - author: Some("workspace-user".into()), - }), - ) - .await?; - browser_ticket_detail(&api, &path.id) -} - fn generic_ticket_state_change(operation: &TicketBackendOperation) -> Option<&TicketStateChange> { match operation { - TicketBackendOperation::SetWorkflowState { change, .. } - | TicketBackendOperation::SetStateField { change, .. } - | TicketBackendOperation::AddStateChanged { change, .. } => Some(change), + TicketBackendOperation::AddStateChanged { change, .. } => Some(change), _ => None, } } @@ -14609,6 +14373,16 @@ async fn execute_ticket_rest_operation( mut operation: TicketBackendOperation, ) -> ApiResult { validate_workspace_scope(api, workspace_id)?; + if let TicketBackendOperation::UpdateCase { ticket, request } = operation { + let Json(updated) = scoped_ticket_case_update( + State(api.clone()), + headers, + AxumPath((workspace_id.to_string(), ticket)), + Json(request), + ) + .await?; + return Ok(TicketBackendOperationResult::Ticket(updated)); + } validate_ticket_operation_repository_keys(api, workspace_id, &operation)?; let mut backend = SqliteTicketBackend::open_verified( api.config.database_path.clone(), @@ -14631,8 +14405,7 @@ async fn execute_ticket_rest_operation( && (generic_ticket_state_change(&operation).is_some() || matches!( operation, - TicketBackendOperation::Close { .. } - | TicketBackendOperation::QueueReady { .. } + TicketBackendOperation::QueueReady { .. } | TicketBackendOperation::MarkReady { .. } )) { @@ -14701,53 +14474,10 @@ async fn execute_ticket_rest_operation( backend = backend.with_event_attributes(event_attributes); } - // Older transport-shaped state/close calls are adapters to the same Ticket - // decision boundary, never alternative completion authorities. - if let Some(before) = before.as_ref() { - let decision = match &operation { - TicketBackendOperation::SetWorkflowState { change, .. } - | TicketBackendOperation::AddStateChanged { change, .. } => Some(( - TicketWorkflowState::parse(&change.to) - .ok_or_else(|| Error::InvalidInput("invalid Ticket state".into()))?, - TicketWorkflowState::parse(&change.from) - .ok_or_else(|| Error::InvalidInput("invalid expected Ticket state".into()))?, - if change.body.as_str().is_empty() { - change.reason.clone() - } else { - format!("{}\n\n{}", change.reason, change.body.as_str()) - }, - change.author.clone(), - change.references.clone(), - )), - TicketBackendOperation::Close { resolution, .. } => Some(( - TicketWorkflowState::Closed, - before.meta.workflow_state, - resolution.as_str().to_string(), - source - .as_ref() - .map(|source| format!("worker:{}/{}", source.runtime_id, source.worker_id)) - .or_else(|| Some("workspace-user".into())), - Vec::new(), - )), - _ => None, - }; - if let Some((state, expected_state, reason, author, references)) = decision { - backend - .update_state( - &before.meta.id, - ticket::TicketStateUpdate { - operation_key: new_id("tdecision"), - expected_content_digest: ticket::ticket_content_digest(before), - expected_state, - state, - reason, - author, - references, - }, - ) - .map_err(Error::from)?; - return Ok(TicketBackendOperationResult::Unit); - } + if generic_ticket_state_change(&operation).is_some() { + return Err(Error::InvalidInput( + "Ticket case decisions require TicketCaseUpdate with explicit content/state/closure-key CAS and replay key".into() + ).into()); } let (result, created_ticket) = match operation { @@ -14791,8 +14521,8 @@ async fn execute_ticket_rest_operation( api, workspace_id, ticket_id, - TicketWorkflowState::Ready.as_str(), - TicketWorkflowState::Queued.as_str(), + "open", + "open", source.clone(), ); } @@ -15015,22 +14745,6 @@ async fn scoped_add_ticket_thread_event( ticket_rest_unit(result) } -async fn scoped_add_ticket_state_change( - State(api): State, - AxumPath((workspace_id, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult { - let _ = scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, id)), - Json(request), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - async fn scoped_add_ticket_intake_summary( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, @@ -15059,41 +14773,6 @@ struct InternalTicketMarkReadyRequest { intake_summary: Option, } -async fn scoped_set_ticket_state_field( - State(api): State, - AxumPath((workspace_id, id, field)): AxumPath<(String, String, String)>, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult { - if field != "state" { - return Err(Error::InvalidInput("unsupported Ticket state field".into()).into()); - } - let _ = scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, id)), - Json(request), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - -async fn scoped_set_ticket_workflow_state( - State(api): State, - AxumPath((workspace_id, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult { - let _ = scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, id)), - Json(request), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - async fn scoped_mark_ticket_ready( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, @@ -16680,34 +16359,11 @@ async fn scoped_complete_merge_request( .map_err(Into::into) } -async fn scoped_complete_ticket( +async fn scoped_ticket_case_update( State(api): State, headers: HeaderMap, AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(input): Json, -) -> ApiResult> { - scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, ticket_id)), - Json(ticket::TicketStateUpdate { - operation_key: input.operation_key, - expected_content_digest: input.expected_content_digest, - expected_state: input.expected_state, - state: TicketWorkflowState::Done, - reason: input.reason, - references: input.references, - author: None, - }), - ) - .await -} - -async fn scoped_ticket_state_update( - State(api): State, - headers: HeaderMap, - AxumPath((workspace_id, ticket_id)): AxumPath<(String, String)>, - Json(mut request): Json, + Json(mut request): Json, ) -> ApiResult> { validate_workspace_scope(&api, &workspace_id)?; let ticket_id = resolve_workspace_ticket_reference(&api, &workspace_id, &ticket_id)?; @@ -16743,14 +16399,14 @@ async fn scoped_ticket_state_update( .map(|(key, value)| (key.clone(), value.clone())) .collect() } else { - context.attributes("state_update") + context.attributes("case_update") }; backend = backend.with_event_attributes(attributes); } else { request.author = Some("workspace-user".into()); } let ticket = backend - .update_state(&ticket_id, request) + .update_case(&ticket_id, request) .map_err(Error::from)?; Ok(Json(ticket)) } @@ -16762,30 +16418,6 @@ fn reject_non_browser_reopen_auth(headers: &HeaderMap) -> Result<()> { Ok(()) } -async fn scoped_close_ticket_record( - State(api): State, - AxumPath((workspace_id, id)): AxumPath<(String, String)>, - headers: HeaderMap, - Json(request): Json, -) -> ApiResult { - let _ = scoped_ticket_state_update( - State(api), - headers, - AxumPath((workspace_id, id)), - Json(ticket::TicketStateUpdate { - state: TicketWorkflowState::Closed, - operation_key: request.operation_key, - expected_content_digest: request.expected_content_digest, - expected_state: request.expected_state, - reason: request.reason, - references: request.references, - author: request.author, - }), - ) - .await?; - Ok(StatusCode::NO_CONTENT) -} - async fn scoped_record_ticket_relation( State(api): State, AxumPath((workspace_id, id)): AxumPath<(String, String)>, @@ -16943,11 +16575,8 @@ fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketI | TicketBackendOperation::AddEvent { id, .. } | TicketBackendOperation::AddStateChanged { id, .. } | TicketBackendOperation::AddIntakeSummary { id, .. } - | TicketBackendOperation::SetStateField { id, .. } - | TicketBackendOperation::SetWorkflowState { id, .. } | TicketBackendOperation::MarkReady { id, .. } | TicketBackendOperation::QueueReady { id, .. } - | TicketBackendOperation::Close { id, .. } | TicketBackendOperation::AddTicketRelation { id, .. } | TicketBackendOperation::RemoveTicketRelation { id, .. } | TicketBackendOperation::AddOrchestrationPlanRecord { id, .. } => Some(id), @@ -16982,9 +16611,7 @@ fn bind_worker_ticket_operation_source( TicketBackendOperation::Create { input } => input.author = Some(author), TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author), TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author), - TicketBackendOperation::AddStateChanged { change, .. } - | TicketBackendOperation::SetStateField { change, .. } - | TicketBackendOperation::SetWorkflowState { change, .. } => change.author = Some(author), + TicketBackendOperation::AddStateChanged { change, .. } => change.author = Some(author), TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author), TicketBackendOperation::MarkReady { request, .. } => { request.author = Some(author.clone()); @@ -17009,12 +16636,10 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati TicketBackendOperation::EditItem { .. } => "edit_item", TicketBackendOperation::AddEvent { .. } => "add_event", TicketBackendOperation::AddStateChanged { .. } => "add_state_changed", + TicketBackendOperation::UpdateCase { .. } => "case_update", TicketBackendOperation::AddIntakeSummary { .. } => "add_intake_summary", - TicketBackendOperation::SetStateField { .. } => "set_state_field", - TicketBackendOperation::SetWorkflowState { .. } => "set_workflow_state", TicketBackendOperation::MarkReady { .. } => "mark_ready", TicketBackendOperation::QueueReady { .. } => "queue_ready", - TicketBackendOperation::Close { .. } => "close", TicketBackendOperation::AddTicketRelation { .. } => "add_relation", TicketBackendOperation::RemoveTicketRelation { .. } => "remove_relation", TicketBackendOperation::AddOrchestrationPlanRecord { .. } => "add_plan_record", @@ -17028,8 +16653,8 @@ fn ticket_operation_initial_state(operation: &TicketBackendOperation) -> String .workflow_state .as_ref() .map(|state| state.as_str().to_string()) - .unwrap_or_else(|| TicketWorkflowState::Planning.as_str().to_string()), - _ => TicketWorkflowState::Planning.as_str().to_string(), + .unwrap_or_else(|| TicketState::Open.as_str().to_string()), + _ => TicketState::Open.as_str().to_string(), } } @@ -17106,9 +16731,46 @@ fn orchestrator_interested( api: &WorkspaceApi, workspace_id: &str, ticket_id: &str, - state: &str, + _state: &str, ) -> Result> { - if !matches!(state, "queued" | "inprogress") { + let backend = browser_ticket_backend(api)?; + let ticket = backend.show(ticket_id.into()).map_err(Error::from)?; + if ticket.meta.workflow_state != TicketState::Open { + return Ok(None); + } + let has_unfinished_work = api + .store + .get_active_ticket_worker_assignment(workspace_id, ticket_id)? + .is_some(); + // Transitional routing continues to require an explicit queue request or + // unfinished work. Openness alone is not a routing request. A later case + // decision fences older queue receipts; T-741/742 will replace this path. + let explicitly_queued = ticket + .events + .iter() + .rev() + .find_map(|event| { + if event.attributes.contains_key("queue_root_ticket") { + Some(true) + } else if event.attributes.contains_key("case_action") { + Some(false) + } else { + None + } + }) + .unwrap_or_else(|| { + // Old queued/inprogress cases may predate queue receipts. This frozen + // migration handoff preserves their intent, but any new case decision + // fences it so reopening never revives an old routing request. + ticket.case_history.is_empty() + && ticket + .legacy_workflow_snapshot + .as_ref() + .is_some_and(|snapshot| { + matches!(snapshot.workflow_state.as_str(), "queued" | "inprogress") + }) + }); + if !has_unfinished_work && !explicitly_queued { return Ok(None); } active_orchestrator_assignment(api, workspace_id, ticket_id) @@ -18660,28 +18322,16 @@ fn check_orchestrator_queue_attention( return; }; let Ok(mut queued) = backend.list(ticket::TicketListQuery::states([ - ticket::TicketListState::Queued, + ticket::TicketListState::Open, ])) else { return; }; queued.retain(|ticket| { - orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "queued") + orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "open") .ok() .flatten() .is_some() }); - let Ok(mut inprogress) = backend.list(ticket::TicketListQuery::states([ - ticket::TicketListState::InProgress, - ])) else { - return; - }; - inprogress.retain(|ticket| { - orchestrator_interested(api, &api.config.workspace_id, &ticket.id, "inprogress") - .ok() - .flatten() - .is_some() - }); - queued.extend(inprogress); queued.sort_by(|left, right| left.id.cmp(&right.id)); if queued.is_empty() { state.lock().unwrap_or_else(|p| p.into_inner()).accepted = None; @@ -32749,6 +32399,7 @@ impl From for ApiError { ticket::TicketError::Locked { .. } => "ticket_locked", ticket::TicketError::Conflict(_) | ticket::TicketError::StaleWorkflowState { .. } + | ticket::TicketError::StaleClosureOperationKey { .. } | ticket::TicketError::StaleContent { .. } | ticket::TicketError::InvalidWorkflowTransition { .. } | ticket::TicketError::BlockingRelations(_) @@ -32869,6 +32520,7 @@ fn api_error_status(error: &Error) -> StatusCode { | ticket::TicketError::Conflict(_) | ticket::TicketError::StaleContent { .. } | ticket::TicketError::StaleWorkflowState { .. } + | ticket::TicketError::StaleClosureOperationKey { .. } | ticket::TicketError::OperationFingerprintMismatch { .. }, ) => StatusCode::CONFLICT, Error::Ticket( @@ -37758,8 +37410,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::InProgress.as_str() + .state + .as_str(), + TicketState::Open.as_str() ); let current = fixture .api @@ -37786,7 +37439,7 @@ mod tests { let api = test_api(workspace.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Queued Ticket"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); let ticket = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket.id); @@ -37814,8 +37467,8 @@ mod tests { assert!(result.is_err()); assert_eq!( - api.authority.ticket(&ticket.id).unwrap().state, - TicketWorkflowState::Queued.as_str() + api.authority.ticket(&ticket.id).unwrap().state.as_str(), + TicketState::Open.as_str() ); assert!( api.store @@ -38235,7 +37888,7 @@ mod tests { .unwrap(); let controller = RuntimeWorkerRef::new(controller.runtime_id, controller.worker_id); let mut input = ticket::NewTicket::new("Guarded attachment spawn"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); input.targets = vec![ test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), @@ -38530,7 +38183,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); } @@ -38729,7 +38382,7 @@ mod tests { .unwrap(); let controller = RuntimeWorkerRef::new(controller.runtime_id, controller.worker_id); let mut ticket = ticket::NewTicket::new("Strict remote guarded attachment spawn"); - ticket.workflow_state = Some(TicketWorkflowState::Queued); + ticket.workflow_state = Some(TicketState::Open); ticket.targets = vec![ test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), @@ -48112,7 +47765,7 @@ mod tests { let ready = backend .mark_ready(TicketIdOrSlug::Id(ticket_ref.id.clone()), request.clone()) .unwrap(); - assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(ready.meta.workflow_state, TicketState::Open); assert_eq!( ready.meta.targets, vec![test_ticket_target( @@ -48158,7 +47811,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(edited.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(edited.meta.workflow_state, TicketState::Open); assert_eq!( edited.meta.targets[0].ref_selector.as_deref(), Some("other") @@ -48166,17 +47819,20 @@ mod tests { let mut missing = ticket::NewTicket::new("Missing target"); set_test_ticket_target(&mut missing, "unknown", "develop"); assert!(backend.create(missing).is_err()); - backend - .set_workflow_state( - ticket_ref.id.into(), - TicketStateChange::new( - "ready", - "inprogress", - "Direct progress judgment", - "No Worker start implied", + let observed = backend.show(ticket_ref.id.clone().into()).unwrap(); + let closed = backend + .update_case( + &ticket_ref.id, + case_test_request( + &observed, + "direct-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, ), ) .unwrap(); + assert_eq!(closed.meta.workflow_state, TicketState::Closed); } #[tokio::test] @@ -48211,7 +47867,7 @@ mod tests { }, ) .unwrap(); - assert_eq!(ready.meta.workflow_state, TicketWorkflowState::Ready); + assert_eq!(ready.meta.workflow_state, TicketState::Open); assert_eq!(ready.meta.targets[0].ref_selector, None); assert!( validated_ticket_resource_targets(&api, &reference.id).unwrap()[0] @@ -48229,7 +47885,7 @@ mod tests { let writable_repository_id = test_repository_id(&api); let docs_repository_id = test_repository_id_by_key(&api, "docs"); let mut input = ticket::NewTicket::new("Read-only MR target"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); input.targets = vec![ test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), @@ -48451,10 +48107,10 @@ mod tests { let inputs = execution.take_inputs(); let expected_states = [ - ("ready", "queued"), - ("inprogress", "inprogress"), - ("inprogress", "inprogress"), - ("inprogress", "inprogress"), + ("open", "open"), + ("open", "open"), + ("open", "open"), + ("open", "open"), ]; assert_eq!(inputs.len(), expected_states.len()); for ((recipient, content), (previous_state, current_state)) in @@ -48473,6 +48129,23 @@ mod tests { } } + fn case_test_request( + before: &ticket::Ticket, + key: &str, + action: ticket::TicketCaseAction, + ) -> ticket::TicketCaseUpdate { + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(before), + expected_state: before.meta.workflow_state, + expected_closure_operation_key: before.closure_operation_key.clone(), + operation_key: key.into(), + action, + explanation: "User case judgment".into(), + references: vec![], + author: None, + } + } + async fn seed_live_test_coder_assignment( api: &WorkspaceApi, ticket_id: &str, @@ -48521,20 +48194,25 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Assigned investigation"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketState::Open); let ticket = backend.create(input.clone()).unwrap(); let _owner = seed_live_test_coder_assignment(&api, &ticket.id, "owner").await; let other_ticket = backend.create(input).unwrap(); let other = seed_live_test_coder_assignment(&api, &other_ticket.id, "other").await; let before = backend.show(ticket.id.clone().into()).unwrap(); - let request = server_api::CompleteTicketRequest { + let request = ticket::TicketCaseUpdate { + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + expected_closure_operation_key: None, + author: None, operation_key: "forged".into(), expected_content_digest: ticket::ticket_content_digest(&before), - expected_state: TicketWorkflowState::InProgress, - reason: "Not my task".into(), + expected_state: TicketState::Open, + explanation: "Not my task".into(), references: Vec::new(), }; - let response = scoped_complete_ticket( + let response = scoped_ticket_case_update( State(api.clone()), ticket_check_headers(&other), AxumPath((TEST_WORKSPACE_ID.into(), ticket.id.clone())), @@ -48544,26 +48222,6 @@ mod tests { .unwrap_err() .into_response(); assert_eq!(response.status(), StatusCode::FORBIDDEN); - let response = scoped_transition_ticket_state( - State(api.clone()), - ticket_check_headers(&other), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.into(), - id: ticket.id.clone(), - }), - Json(server_api::BrowserTransitionTicketStateRequest { - state: server_api::BrowserTicketWorkflowState::Done, - expected_state: server_api::BrowserTicketWorkflowState::Inprogress, - operation_key: "browser-forged".into(), - expected_content_digest: ticket::ticket_content_digest(&before), - reason: "Not human authority".into(), - body: None, - }), - ) - .await - .unwrap_err() - .into_response(); - assert_eq!(response.status(), StatusCode::FORBIDDEN); assert_eq!(backend.show(ticket.id.into()).unwrap(), before); } @@ -48573,20 +48231,25 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Research complete without repository"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketState::Open); let reference = backend.create(input).unwrap(); let coder = seed_live_test_coder_assignment(&api, &reference.id, "completed-coder").await; let before = backend.show(reference.id.clone().into()).unwrap(); - let request = server_api::CompleteTicketRequest { + let request = ticket::TicketCaseUpdate { + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + expected_closure_operation_key: None, + author: None, operation_key: "research-done".into(), expected_content_digest: ticket::ticket_content_digest(&before), - expected_state: TicketWorkflowState::InProgress, - reason: "## 完了理由\n\n日本語の長い検証結果を省略せず記録する。\n\n" + expected_state: TicketState::Open, + explanation: "## 完了理由\n\n日本語の長い検証結果を省略せず記録する。\n\n" .repeat(1024), references: Vec::new(), }; let call = |request| { - scoped_complete_ticket( + scoped_ticket_case_update( State(api.clone()), ticket_check_headers(&coder), AxumPath((TEST_WORKSPACE_ID.into(), reference.id.clone())), @@ -48594,10 +48257,10 @@ mod tests { ) }; let Json(completed) = call(request.clone()).await.unwrap(); - assert_eq!(completed.meta.workflow_state, TicketWorkflowState::Done); + assert_eq!(completed.meta.workflow_state, TicketState::Closed); let event = completed.events.last().unwrap(); - assert_eq!(event.reason.as_deref(), Some(request.reason.as_str())); - assert_eq!(event.body.as_str(), request.reason); + assert_eq!(event.reason.as_deref(), Some(request.explanation.as_str())); + assert_eq!(event.body.as_str(), request.explanation); assert_eq!( backend.show(reference.id.clone().into()).unwrap(), completed @@ -48614,9 +48277,9 @@ mod tests { for field in ["reason", "content_digest", "state", "references"] { let mut changed = request.clone(); match field { - "reason" => changed.reason = "different judgment".into(), + "reason" => changed.explanation = "different judgment".into(), "content_digest" => changed.expected_content_digest = "stale".into(), - "state" => changed.expected_state = TicketWorkflowState::Planning, + "state" => changed.expected_state = TicketState::Closed, "references" => changed.references.push(ticket::TicketReference { kind: "document".into(), target: "other".into(), @@ -48647,7 +48310,7 @@ mod tests { ); let mut fresh = request; fresh.operation_key = "unassigned-new-operation".into(); - fresh.expected_state = TicketWorkflowState::Done; + fresh.expected_state = TicketState::Closed; assert!(call(fresh).await.is_err()); } @@ -48656,7 +48319,7 @@ mod tests { let dir = tempfile::tempdir().unwrap(); let (api, execution) = test_api_with_recording_backend(dir.path()).await; let mut input = ticket::NewTicket::new("Close and reopen responsibility"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut input, "test-repository", "develop"); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); let old_coder = seed_live_test_coder_assignment(&api, &ticket.id, "old-coder").await; @@ -48673,35 +48336,38 @@ mod tests { let orchestrator = started.worker.unwrap(); let orchestrator = RuntimeWorkerRef::new(orchestrator.runtime_id, orchestrator.worker_id); execution.take_inputs(); - let status = scoped_close_ticket_record( + let before = browser_ticket_backend(&api) + .unwrap() + .show(ticket.id.clone().into()) + .unwrap(); + let Json(closed) = scoped_ticket_case_update( State(api.clone()), - AxumPath((TEST_WORKSPACE_ID.to_string(), ticket.id.clone())), HeaderMap::new(), - Json(ticket::TicketCompletion { - operation_key: "normal-close".into(), - expected_content_digest: api.authority.ticket(&ticket.id).unwrap().content_digest, - expected_state: TicketWorkflowState::InProgress, - reason: "Normal close ends implementation work".into(), - references: Vec::new(), - author: None, - }), + AxumPath((TEST_WORKSPACE_ID.into(), ticket.id.clone())), + Json(case_test_request( + &before, + "normal-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + )), ) .await .unwrap(); - assert_eq!(status, StatusCode::NO_CONTENT); assert!(execution.take_inputs().is_empty()); - - for state in ["closed", "planning"] { - if state == "planning" { - // No public reopen operation exists. Seed a reopened authority - // record after normal API close; the durable release must survive. - let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); - crate::store::configure_sqlite(&conn).unwrap(); - conn.execute( - "UPDATE typed_tickets SET workflow_state = 'planning', status = 'open' - WHERE workspace_id = ?1 AND ticket_id = ?2", - rusqlite::params![TEST_WORKSPACE_ID, ticket.id], + for state in ["closed", "open"] { + if state == "open" { + let _ = scoped_ticket_case_update( + State(api.clone()), + HeaderMap::new(), + AxumPath((TEST_WORKSPACE_ID.into(), ticket.id.clone())), + Json(case_test_request( + &closed, + "reopen", + ticket::TicketCaseAction::Reopen, + )), ) + .await .unwrap(); } let path = || { @@ -48711,7 +48377,7 @@ mod tests { }) }; let Json(detail) = scoped_get_ticket(State(api.clone()), path()).await.unwrap(); - assert_eq!(detail.state, state); + assert_eq!(detail.state.as_str(), state); assert_eq!(detail.current_worker.unwrap().assignment_id, "old-coder"); assert!( detail @@ -48719,9 +48385,9 @@ mod tests { .iter() .any(|assignment| assignment.assignment_id == old_orchestrator_id) ); - assert_eq!( + assert!( detail.action_eligibility.can_assign_orchestrator, - state == "planning" + "retained responsibility alone is not an active assignment: state={state}" ); let Json(assignments) = scoped_list_ticket_assignments(State(api.clone()), path()) .await @@ -48860,7 +48526,7 @@ mod tests { .unwrap() .show(TicketIdOrSlug::Id(ticket.id.clone())) .unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(queued.meta.workflow_state, TicketState::Open); assert_eq!( queued .events @@ -48874,7 +48540,7 @@ mod tests { assert_eq!(inputs[0].0.worker_id.to_string(), orchestrator.worker_id); assert_eq!( inputs[0].1, - ticket_notification_content(ticket.resource_key.as_deref().unwrap(), "ready", "queued") + ticket_notification_content(ticket.resource_key.as_deref().unwrap(), "open", "open") ); assert!( api.store @@ -48971,7 +48637,7 @@ mod tests { } sync_runtime_worker_workdir_attachments(&api, &worker).unwrap(); let mut input = ticket::NewTicket::new("Manual multi-target Coder"); - input.workflow_state = Some(TicketWorkflowState::Ready); + input.workflow_state = Some(TicketState::Open); input.targets = vec![ test_ticket_target("test-repository", "develop", TicketTargetAccess::ReadWrite), test_ticket_target("docs", "develop", TicketTargetAccess::ReadOnly), @@ -49050,8 +48716,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::InProgress.as_str() + .state + .as_str(), + TicketState::Open.as_str() ); let links = fixture .api @@ -49284,8 +48951,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::Ready.as_str(), + .state + .as_str(), + TicketState::Open.as_str(), "case={case}" ); assert!( @@ -49355,8 +49023,9 @@ mod tests { .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - TicketWorkflowState::Ready.as_str() + .state + .as_str(), + TicketState::Open.as_str() ); assert!( fixture @@ -49622,7 +49291,7 @@ mod tests { .unwrap(); let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Implementation cancellation"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + input.workflow_state = Some(TicketState::Open); let ticket = backend.create(input).unwrap(); api.store .set_current_ticket_worker_assignment( @@ -49658,7 +49327,7 @@ mod tests { scoped_cancel_ticket_implementation(State(api.clone()), path(), request()) .await .unwrap(); - assert_eq!(cancelled.state, TicketWorkflowState::Ready.as_str()); + assert_eq!(cancelled.state.as_str(), TicketState::Open.as_str()); assert!(cancelled.current_worker.is_none()); assert!( !cancelled @@ -49671,7 +49340,7 @@ mod tests { let Json(replayed) = scoped_cancel_ticket_implementation(State(api), path(), request()) .await .unwrap(); - assert_eq!(replayed.state, TicketWorkflowState::Ready.as_str()); + assert_eq!(replayed.state.as_str(), TicketState::Open.as_str()); } #[tokio::test] @@ -49983,46 +49652,37 @@ mod tests { } #[tokio::test] - async fn browser_progress_decisions_allow_direct_completion_and_reopen_with_cas() { + async fn browser_case_decisions_close_correct_and_reopen_without_assets() { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let reference = backend .create(ticket::NewTicket::new("No asset needed")) .unwrap(); - for (index, state) in [ - server_api::BrowserTicketWorkflowState::Inprogress, - server_api::BrowserTicketWorkflowState::Done, - server_api::BrowserTicketWorkflowState::Closed, - server_api::BrowserTicketWorkflowState::Planning, - ] - .into_iter() - .enumerate() - { + let actions = [ + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ticket::TicketCaseAction::CorrectClosure { + reason: ticket::TicketClosureReason::Duplicate, + }, + ticket::TicketCaseAction::Reopen, + ]; + for (index, action) in actions.into_iter().enumerate() { let before = backend.show(reference.id.clone().into()).unwrap(); - let expected_state = - serde_json::from_value(serde_json::json!(before.meta.workflow_state.as_str())) - .unwrap(); - let request = server_api::BrowserTransitionTicketStateRequest { - state: state.clone(), - operation_key: format!("decision-{index}"), - expected_content_digest: ticket::ticket_content_digest(&before), - expected_state, - reason: "Human progress judgment".into(), - body: None, - }; - let Json(after) = scoped_transition_ticket_state( + let Json(after) = scoped_ticket_case_update( State(api.clone()), HeaderMap::new(), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.into(), - id: reference.id.clone(), - }), - Json(request), + AxumPath((TEST_WORKSPACE_ID.into(), reference.id.clone())), + Json(case_test_request( + &before, + &format!("decision-{index}"), + action, + )), ) .await .unwrap(); - assert_eq!(after.state, browser_ticket_workflow_state(state).as_str()); + assert_eq!(after.case_history.len(), index + 1); assert!( api.store .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &reference.id) @@ -50031,27 +49691,31 @@ mod tests { ); } let before = backend.show(reference.id.clone().into()).unwrap(); - let response = scoped_transition_ticket_state( + assert_eq!(before.meta.workflow_state, TicketState::Open); + assert_eq!( + before.case_history[0].action, + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned + } + ); + let mut stale = case_test_request( + &before, + "stale", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + stale.expected_content_digest = "stale-content-digest".into(); + let response = scoped_ticket_case_update( State(api), HeaderMap::new(), - AxumPath(ScopedRecordPath { - workspace_id: TEST_WORKSPACE_ID.into(), - id: reference.id, - }), - Json(server_api::BrowserTransitionTicketStateRequest { - state: server_api::BrowserTicketWorkflowState::Done, - operation_key: "stale".into(), - expected_content_digest: "stale-content-digest".into(), - expected_state: server_api::BrowserTicketWorkflowState::Planning, - reason: "Stale decision".into(), - body: None, - }), + AxumPath((TEST_WORKSPACE_ID.into(), reference.id)), + Json(stale), ) .await .unwrap_err() .into_response(); assert_eq!(response.status(), StatusCode::CONFLICT); - assert_eq!(before.meta.workflow_state, TicketWorkflowState::Planning); } #[tokio::test] @@ -50071,7 +49735,7 @@ mod tests { .unwrap(); assert_eq!(outcome.queued_tickets, vec![ticket.id.clone()]); let queued = backend.show(ticket.id.clone().into()).unwrap(); - assert_eq!(queued.meta.workflow_state, TicketWorkflowState::Queued); + assert_eq!(queued.meta.workflow_state, TicketState::Open); assert!( !queued .events @@ -50088,7 +49752,7 @@ mod tests { ); // If an explicit routing identity exists, retain its fence for later claim. let mut input = ticket::NewTicket::new("Fenced request"); - input.workflow_state = Some(TicketWorkflowState::Ready); + input.workflow_state = Some(TicketState::Open); let fenced = backend.create(input).unwrap(); assign_test_orchestrator(&api, &fenced.id); let _ = scoped_queue_ticket_record( @@ -50114,11 +49778,11 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut first_input = ticket::NewTicket::new("First cycle Ticket"); - first_input.workflow_state = Some(TicketWorkflowState::Ready); + first_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut first_input, "test-repository", "develop"); let first = backend.create(first_input).unwrap(); let mut second_input = ticket::NewTicket::new("Second cycle Ticket"); - second_input.workflow_state = Some(TicketWorkflowState::Ready); + second_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut second_input, "test-repository", "develop"); let second = backend.create(second_input).unwrap(); for (ticket_id, target) in [ @@ -50155,7 +49819,7 @@ mod tests { assert_eq!(outcome.queued_tickets, vec![first.id.clone()]); assert_eq!( backend.show(second.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); assert!( backend @@ -50173,11 +49837,11 @@ mod tests { let api = test_api_with_remote_repository(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut dependency_input = ticket::NewTicket::new("Remote target dependency"); - dependency_input.workflow_state = Some(TicketWorkflowState::Ready); + dependency_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut dependency_input, "test-repository", "remote-feature"); let dependency = backend.create(dependency_input).unwrap(); let mut root_input = ticket::NewTicket::new("Queue root"); - root_input.workflow_state = Some(TicketWorkflowState::Ready); + root_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut root_input, "test-repository", "develop"); let root = backend.create(root_input).unwrap(); backend @@ -50207,7 +49871,7 @@ mod tests { assert_eq!(outcome.queued_tickets, vec![root.id.clone()]); assert_eq!( backend.show(root.id.into()).unwrap().meta.workflow_state, - TicketWorkflowState::Queued + TicketState::Open ); assert_eq!( backend @@ -50215,7 +49879,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::Ready + TicketState::Open ); } @@ -50260,7 +49924,7 @@ mod tests { .unwrap(); let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Queued notification"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); let ticket_ref = backend.create(input).unwrap(); let mut headers = HeaderMap::new(); headers.insert( @@ -50341,7 +50005,7 @@ mod tests { let _ = execution.take_inputs(); let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Queued notification"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); let ticket_ref = backend.create(input).unwrap(); assign_test_orchestrator(&api, ticket_ref.id.as_str()); let missing_recipient = @@ -50405,8 +50069,8 @@ mod tests { .unwrap(); assert_eq!( - api.authority.ticket(&ticket_ref.id).unwrap().state, - TicketWorkflowState::Queued.as_str() + api.authority.ticket(&ticket_ref.id).unwrap().state.as_str(), + TicketState::Open.as_str() ); let warnings = TICKET_NOTIFICATION_DELIVERY_WARNING_CAPTURE .lock() @@ -50420,7 +50084,7 @@ mod tests { assert_eq!(warning.level, "warning"); assert_eq!(warning.event, "ticket_notification_delivery_failed"); assert_eq!(warning.workspace_id, TEST_WORKSPACE_ID); - assert_eq!(warning.current_state, TicketWorkflowState::Queued.as_str()); + assert_eq!(warning.current_state, TicketState::Open.as_str()); assert_eq!(warning.recipient_runtime_id, missing_recipient.runtime_id); assert_eq!(warning.recipient_worker_id, missing_recipient.worker_id); assert_eq!(warning.error_category, "unknown_worker"); @@ -50438,8 +50102,8 @@ mod tests { notifications[0].1, ticket_notification_content( ticket_ref.resource_key.as_deref().unwrap(), - TicketWorkflowState::Queued.as_str(), - TicketWorkflowState::Queued.as_str() + TicketState::Open.as_str(), + TicketState::Open.as_str() ) ); @@ -50472,8 +50136,8 @@ mod tests { .unwrap(); assert_eq!( - api.authority.ticket(&ticket_ref.id).unwrap().state, - TicketWorkflowState::Queued.as_str() + api.authority.ticket(&ticket_ref.id).unwrap().state.as_str(), + TicketState::Open.as_str() ); let warnings = TICKET_NOTIFICATION_DELIVERY_WARNING_CAPTURE .lock() @@ -50509,10 +50173,14 @@ mod tests { init_clean_git_workspace(dir.path()); let (api, execution) = test_api_with_recording_backend(dir.path()).await; let mut input = ticket::NewTicket::new("Attention backlog"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut input, "test-repository", "HEAD"); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); assign_test_orchestrator(&api, &ticket.id); + browser_ticket_backend(&api) + .unwrap() + .queue_ready(ticket.id.into(), "test-request") + .unwrap(); (dir, api, execution) } @@ -50796,10 +50464,14 @@ mod tests { // Change the backlog before retry: uncertain acceptance must never reuse // the ID for a newly rendered payload. let mut next = ticket::NewTicket::new("Changed backlog"); - next.workflow_state = Some(TicketWorkflowState::Queued); + next.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut next, "test-repository", "HEAD"); let next = browser_ticket_backend(&api).unwrap().create(next).unwrap(); assign_test_orchestrator(&api, &next.id); + browser_ticket_backend(&api) + .unwrap() + .queue_ready(next.id.clone().into(), "test-request") + .unwrap(); *execution.input_failure.lock().unwrap() = None; assert_eq!( start_attention_test_orchestrator(&api).await.disposition, @@ -51116,10 +50788,14 @@ mod tests { .await .unwrap(); let mut input = ticket::NewTicket::new("Real notification backlog"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut input, "test-repository", "HEAD"); let ticket = browser_ticket_backend(&api).unwrap().create(input).unwrap(); assign_test_orchestrator(&api, &ticket.id); + browser_ticket_backend(&api) + .unwrap() + .queue_ready(ticket.id.clone().into(), "test-request") + .unwrap(); let started = start_attention_test_orchestrator(&api).await; let worker = RuntimeWorkerRef::new( EMBEDDED_WORKER_RUNTIME_ID, @@ -51323,10 +50999,14 @@ mod tests { let (api, execution) = test_api_with_recording_backend(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Recover queued work"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut input, "test-repository", "HEAD"); let ticket_ref = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket_ref.id); + browser_ticket_backend(&api) + .unwrap() + .queue_ready(ticket_ref.id.clone().into(), "test-request") + .unwrap(); let Json(started) = scoped_start_workspace_orchestrator( State(api.clone()), @@ -51392,7 +51072,7 @@ mod tests { let candidates = backend .list(ticket::TicketListQuery::states([ - ticket::TicketListState::Queued, + ticket::TicketListState::Open, ])) .unwrap(); let mut truncated_candidates = (1..=worker::OrchestratorQueueAttentionContext::MAX_TICKETS @@ -51525,7 +51205,7 @@ mod tests { let api = test_api(dir.path()).await; let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("Investigation without a checkout"); - input.workflow_state = Some(TicketWorkflowState::Queued); + input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut input, "test-repository", "develop"); let ticket = backend.create(input).unwrap(); assign_test_orchestrator(&api, &ticket.id); @@ -51581,7 +51261,7 @@ mod tests { .unwrap() .meta .workflow_state, - TicketWorkflowState::InProgress + TicketState::Open ); let Json(replay) = scoped_create_runtime_worker( State(api), @@ -51645,7 +51325,7 @@ mod tests { id: ticket_id.clone(), }; let mut related_input = ticket::NewTicket::new("Related Browser Ticket"); - related_input.workflow_state = Some(TicketWorkflowState::Ready); + related_input.workflow_state = Some(TicketState::Open); set_test_ticket_target(&mut related_input, "test-repository", "develop"); let related_ticket_id = browser_ticket_backend(&api) .unwrap() @@ -51729,7 +51409,7 @@ mod tests { ) .await .unwrap(); - assert_eq!(ready.state, "ready"); + assert_eq!(ready.state.as_str(), "open"); assign_test_orchestrator(&api, &ticket_id); assign_test_orchestrator(&api, &related_ticket_id); let Json(ready_detail) = scoped_get_ticket(State(api.clone()), AxumPath(path())) @@ -51759,26 +51439,33 @@ mod tests { let Json(queued) = scoped_get_ticket(State(api.clone()), AxumPath(path())) .await .unwrap(); - assert_eq!(queued.state, "queued"); + assert_eq!(queued.state.as_str(), "open"); assert_eq!(queued.queued_by.as_deref(), Some("workspace-web")); assert_eq!(queued.relations.blockers.len(), 1); assert_eq!(queued.relations.blockers[0].reason_kind, "depends_on"); - let Json(closed) = scoped_close_ticket( + let before = browser_ticket_backend(&api) + .unwrap() + .show(ticket_id.clone().into()) + .unwrap(); + let mut request = case_test_request( + &before, + "browser-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + ); + request.explanation = "Closed through the Browser API.".into(); + let Json(closed) = scoped_ticket_case_update( State(api), HeaderMap::new(), - AxumPath(path()), - Json(server_api::BrowserCloseTicketRequest { - operation_key: "browser-close".into(), - expected_content_digest: queued.content_digest.clone(), - expected_state: server_api::BrowserTicketWorkflowState::Queued, - resolution: "Closed through the Browser API.".to_string(), - }), + AxumPath((TEST_WORKSPACE_ID.into(), ticket_id.clone())), + Json(request), ) .await .unwrap(); - assert_eq!(closed.state, "closed"); + assert_eq!(closed.meta.workflow_state, TicketState::Closed); assert_eq!( - closed.resolution.as_deref(), + closed.resolution.as_ref().map(|text| text.as_str()), Some("Closed through the Browser API.") ); } @@ -53336,7 +53023,7 @@ mod tests { "INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit - ) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', '', 'inprogress', 1)", + ) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', '', 'open', 1)", rusqlite::params![api.config.workspace_id, ticket_id], ) .unwrap(); @@ -56271,7 +55958,7 @@ mod tests { let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); crate::store::configure_sqlite(&conn).unwrap(); - for (state, status) in [("done", "open"), ("closed", "closed")] { + for (state, status) in [("closed", "closed"), ("open", "open")] { conn.execute( "UPDATE typed_tickets SET workflow_state = ?1, status = ?2 WHERE workspace_id = ?3 AND ticket_id = ?4", @@ -56320,7 +56007,7 @@ mod tests { ); let conn = rusqlite::Connection::open(&api.config.database_path).unwrap(); crate::store::configure_sqlite(&conn).unwrap(); - for (state, status) in [("done", "open"), ("closed", "closed")] { + for (state, status) in [("closed", "closed"), ("open", "open")] { conn.execute( "UPDATE typed_tickets SET workflow_state = ?1, status = ?2 WHERE workspace_id = ?3 AND ticket_id = ?4", @@ -58825,7 +58512,7 @@ mod tests { &config.database_path, TEST_WORKSPACE_ID, "API Ticket", - ticket::TicketWorkflowState::Ready, + ticket::TicketState::Open, ); let sqlite_store = SqliteWorkspaceStore::open(&config.database_path).unwrap(); sqlite_store @@ -58991,7 +58678,7 @@ mod tests { let tickets = get_json(app.clone(), "/api/tickets").await; assert_eq!(tickets["items"][0]["title"], "API Ticket"); - assert_eq!(tickets["items"][0]["state"], "ready"); + assert_eq!(tickets["items"][0]["state"], "open"); let objectives = get_json(app.clone(), "/api/objectives").await; assert_eq!(objectives["items"].as_array().unwrap().len(), 1); @@ -61382,8 +61069,8 @@ INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit ) VALUES - ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1), - ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1); + ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1), + ('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1); INSERT INTO workspace_resource_keys ( workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at ) VALUES @@ -61877,7 +61564,7 @@ VALUES ('0192f0e8-4d84-7d6e-a000-000000000001', 'ticket', 3); database_path: &Path, workspace_id: &str, title: &str, - state: ticket::TicketWorkflowState, + state: ticket::TicketState, ) -> String { use ticket::TicketBackend as _; diff --git a/crates/workspace-server/src/server/tests/api_logging_tests.rs b/crates/workspace-server/src/server/tests/api_logging_tests.rs index 803f8392a..e4095b829 100644 --- a/crates/workspace-server/src/server/tests/api_logging_tests.rs +++ b/crates/workspace-server/src/server/tests/api_logging_tests.rs @@ -131,8 +131,7 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() { }) .unwrap(); let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Cancellation logging"); - input.workflow_state = Some(TicketWorkflowState::InProgress); + let input = ticket::NewTicket::new("Cancellation logging"); let ticket = backend.create(input).unwrap(); api.store .set_current_ticket_worker_assignment( @@ -197,8 +196,8 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() { "{diagnostics}" ); assert_eq!( - api.authority.ticket(&ticket.id).unwrap().state, - "inprogress" + api.authority.ticket(&ticket.id).unwrap().state.as_str(), + "open" ); // Errors without diagnostics still carry a useful kind and message. @@ -213,7 +212,32 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() { *execution.cancel_failure.lock().unwrap() = None; let (status, body, events) = request_with_logs(app, request("body-secret")).await; assert_eq!(status, StatusCode::OK, "{body}"); - assert_eq!(body["state"], "ready"); + assert_eq!(body["state"], "open"); + assert!( + api.store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + assert!( + api.store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id) + .unwrap() + .is_none() + ); + let assignment_history = api + .store + .list_ticket_worker_assignment_events(TEST_WORKSPACE_ID, &ticket.id, 10) + .unwrap(); + assert!( + assignment_history + .iter() + .any(|event| event.action == "unassigned" + && event.previous_assignment_id.as_deref() == Some("logging-assignment")) + ); + let after = backend.show(ticket.id.into()).unwrap(); + assert!(after.case_history.is_empty()); + assert!(after.current_closure.is_none()); assert!( events.is_empty(), "successful cancellation logged as failure: {events:?}" diff --git a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs index 113d6d056..6a5e473bc 100644 --- a/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_evidence_tests.rs @@ -47,7 +47,6 @@ impl EvidenceApiFixture { assert!(!temp.path().join(".git").exists()); let backend = browser_ticket_backend(&api).unwrap(); let mut input = ticket::NewTicket::new("T716 remote immutable evidence"); - input.workflow_state = Some(TicketWorkflowState::InProgress); input.targets = vec![ticket::TicketTarget { repository_key: "test-repository".into(), ref_selector: Some("develop".into()), @@ -191,12 +190,16 @@ impl EvidenceApiFixture { .unwrap() } - fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion { - ticket::TicketCompletion { - operation_key: "evidence-ticket-completion".into(), + fn closure(&self) -> ticket::TicketCaseUpdate { + ticket::TicketCaseUpdate { + operation_key: "evidence-ticket-closure".into(), expected_content_digest: self.content_digest(), - expected_state: TicketWorkflowState::InProgress, - reason: "Implementation judged complete independently of MR attestation".into(), + expected_state: ticket::TicketState::Open, + expected_closure_operation_key: None, + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: "Implementation judged complete independently of MR attestation".into(), references: Vec::new(), author: Some("fixture".into()), } @@ -297,14 +300,14 @@ impl EvidenceApiFixture { } #[tokio::test] -async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() { +async fn closed_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() { let fixture = EvidenceApiFixture::new().await; let approval = fixture.approve("integration"); let merge = fixture.integrate(&approval); let content_digest = fixture.content_digest(); let completion = fixture .backend - .complete(&fixture.ticket_id, fixture.completion(&approval)) + .update_case(&fixture.ticket_id, fixture.closure()) .unwrap(); assert_eq!(ticket::ticket_content_digest(&completion), content_digest); assert!( @@ -325,7 +328,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_ ); let shown = fixture.show().await; - assert_eq!(shown.state, "done"); + assert_eq!(shown.state.as_str(), "closed"); assert_eq!(shown.content_digest, content_digest); assert!( shown.evidence.complete_for_integration, @@ -395,15 +398,15 @@ async fn latest_postmerge_approval_requires_exact_content_and_stored_source_resu .contains(&"review_after_rescope".into()) ); fixture.assert_filters(false, true, true).await; - let mut outdated_completion = fixture.completion(&integration); - outdated_completion.expected_content_digest = integration.ticket_content_digest.clone(); + let mut outdated_closure = fixture.closure(); + outdated_closure.expected_content_digest = integration.ticket_content_digest.clone(); assert!( fixture .backend - .complete(&fixture.ticket_id, outdated_completion) + .update_case(&fixture.ticket_id, outdated_closure) .is_err() ); - assert_eq!(fixture.show().await.state, "inprogress"); + assert_eq!(fixture.show().await.state.as_str(), "open"); let latest = fixture.approve("latest-content"); assert_eq!(latest.created_at, integration.created_at); @@ -467,7 +470,7 @@ async fn latest_postmerge_approval_requires_exact_content_and_stored_source_resu assert!(wrong.evidence.approved_current_subject); assert!(!wrong.evidence.complete_for_integration); fixture.assert_filters(false, true, true).await; - assert_eq!(fixture.show().await.state, "inprogress"); + assert_eq!(fixture.show().await.state.as_str(), "open"); let repaired = fixture.approve("exact-snapshot"); fixture.assert_filters(true, false, false).await; @@ -488,10 +491,10 @@ async fn latest_postmerge_approval_requires_exact_content_and_stored_source_resu assert_eq!(detail.target.resolved_ref.as_deref(), Some(EVIDENCE_TARGET)); let completion = fixture .backend - .complete(&fixture.ticket_id, fixture.completion(&repaired)) + .update_case(&fixture.ticket_id, fixture.closure()) .unwrap(); assert_eq!(ticket::ticket_content_digest(&completion), content_digest); - assert_eq!(fixture.show().await.state, "done"); + assert_eq!(fixture.show().await.state.as_str(), "closed"); fixture.assert_filters(true, false, false).await; } @@ -645,14 +648,18 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta .unwrap(); fixture .backend - .update_state( + .update_case( &fixture.ticket_id, - ticket::TicketStateUpdate { + ticket::TicketCaseUpdate { operation_key: "close-with-open-mr".into(), expected_content_digest: fixture.content_digest(), - expected_state: TicketWorkflowState::InProgress, - state: TicketWorkflowState::Closed, - reason: "The request was withdrawn; no integration or approval is asserted".into(), + expected_state: ticket::TicketState::Open, + expected_closure_operation_key: None, + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + explanation: "The request was withdrawn; no integration or approval is asserted" + .into(), references: Vec::new(), author: Some("workspace-user".into()), }, @@ -667,13 +674,13 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta serde_json::to_value(before).unwrap() ); let shown = fixture.show().await; - assert_eq!(shown.state, "closed"); + assert_eq!(shown.state.as_str(), "closed"); assert!(!shown.evidence.approved_current_subject); assert_eq!(shown.merge_requests[0].state, "open"); } #[tokio::test] -async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_evidence() { +async fn mrless_completed_closure_is_a_ticket_judgment_not_approved_or_missing_merge_evidence() { let temp = tempfile::tempdir().unwrap(); let api = test_api_with_remote_repository(temp.path()).await; let backend = browser_ticket_backend(&api).unwrap(); @@ -682,13 +689,17 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev .unwrap(); let ticket = backend.show(reference.id.clone().into()).unwrap(); backend - .complete( + .update_case( &reference.id, - ticket::TicketCompletion { + ticket::TicketCaseUpdate { operation_key: "research-result".into(), expected_content_digest: ticket::ticket_content_digest(&ticket), - expected_state: TicketWorkflowState::Planning, - reason: "Answer recorded in the Ticket thread".into(), + expected_state: ticket::TicketState::Open, + expected_closure_operation_key: None, + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: "Answer recorded in the Ticket thread".into(), references: Vec::new(), author: Some("workspace-user".into()), }, @@ -703,7 +714,7 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev .await, ) .unwrap(); - assert_eq!(shown.state, "done"); + assert_eq!(shown.state.as_str(), "closed"); assert!(!shown.evidence.has_merge_request); assert!(!shown.evidence.approved_current_subject); assert!(!shown.evidence.complete_for_integration); @@ -728,8 +739,22 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev } } +// Exercise the generated public API through the router, then reread the real +// Ticket authority. No live Runtime, repository provider, or Worker process runs. +fn case_request(before: &ticket::Ticket, operation_key: &str, action: Value) -> Value { + json!({ + "operation_key": operation_key, + "expected_content_digest": ticket::ticket_content_digest(before), + "expected_state": before.meta.workflow_state, + "expected_closure_operation_key": before.closure_operation_key, + "action": action, + "explanation": "Explicit user case judgment", + "references": [{"kind": "ticket", "target": "T-42"}], + }) +} + #[tokio::test] -async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_receipts() { +async fn case_route_rejects_missing_cas_client_author_and_removed_workflow_routes() { let temp = tempfile::tempdir().unwrap(); let api = test_api_with_remote_repository(temp.path()).await; let backend = browser_ticket_backend(&api).unwrap(); @@ -738,84 +763,319 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re .unwrap(); let before = backend.show(reference.id.clone().into()).unwrap(); let app = build_inner_router(api); - let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id); - for suffix in ["workflow-state", "state-changes", "state-fields/state"] { + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let request = case_request( + &before, + "close", + json!({"action": "close", "reason": "completed"}), + ); + for field in [ + "operation_key", + "expected_content_digest", + "expected_state", + "action", + "explanation", + ] { + let mut missing = request.clone(); + missing.as_object_mut().unwrap().remove(field); request_json( app.clone(), "POST", - &format!("{path}/{suffix}"), - Some(json!({ - "from":"planning","to":"done","reason":"old unguarded request","body":"" - })), + &path, + Some(missing), StatusCode::UNPROCESSABLE_ENTITY, ) .await; } + for (field, value) in [ + ("author", json!("spoofed-client-author")), + ("expected_state", json!("done")), + ("action", json!({"action": "close", "reason": "invented"})), + ] { + let mut invalid = request.clone(); + invalid[field] = value; + request_json( + app.clone(), + "POST", + &path, + Some(invalid), + StatusCode::UNPROCESSABLE_ENTITY, + ) + .await; + } + let legacy = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id); + for suffix in [ + "workflow-state", + "state-changes", + "state-fields/state", + "workflow/close", + "complete", + ] { + // A surviving GET catch-all can produce 405 rather than 404 for an + // absent POST route. Neither status authorizes a legacy mutation. + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("{legacy}/{suffix}")) + .header(CONTENT_TYPE, "application/json") + .body(Body::from(request.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert!( + matches!( + response.status(), + StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED + ), + "legacy POST {suffix}: {}", + response.status() + ); + } + request_json( + app, + "POST", + &format!( + "/workspaces/019d0000-0000-7000-8000-0000000000bb/tickets/{}/case", + reference.id + ), + Some(request), + StatusCode::NOT_FOUND, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), before); +} + +#[tokio::test] +async fn case_route_preserves_corrected_history_replay_and_reopen_aba_guard_without_mr() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api_with_remote_repository(temp.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new( + "Case judgments without repository work", + )) + .unwrap(); + let before = backend.show(reference.id.clone().into()).unwrap(); + let digest = ticket::ticket_content_digest(&before); + let app = build_inner_router(api); + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let detail_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id); + let close = case_request( + &before, + "guarded-close", + json!({"action": "close", "reason": "completed"}), + ); + let response = request_json( + app.clone(), + "POST", + &path, + Some(close.clone()), + StatusCode::OK, + ) + .await; + let summary: server_api::TicketSummary = serde_json::from_value(response).unwrap(); + assert_eq!(summary.0.workflow_state, ticket::TicketState::Closed); + let closed = backend.show(reference.id.clone().into()).unwrap(); + assert_eq!( + closed.current_closure.as_ref().unwrap().author, + "workspace-user" + ); + assert_eq!( + closed.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Completed + ); + assert_eq!(closed.case_history.len(), 1); + assert_eq!( + closed.case_history[0].references, + closed.current_closure.as_ref().unwrap().references + ); + + let correction = case_request( + &closed, + "correct-closure", + json!({"action": "correct_closure", "reason": {"other": "Answer superseded by user decision"}}), + ); request_json( app.clone(), "POST", - &format!("{path}/workflow/close"), - Some(json!("unguarded close")), - StatusCode::UNPROCESSABLE_ENTITY, + &path, + Some(correction.clone()), + StatusCode::OK, ) .await; - let close = json!({ - "operation_key":"guarded-close","expected_content_digest":ticket::ticket_content_digest(&before), - "expected_state":"planning","reason":"No further work required","author":"spoofed-client-author" - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("{path}/workflow/close")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(close.to_string())) - .unwrap(), - ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); - let closed = backend.show(reference.id.clone().into()).unwrap(); - assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed); - let receipt = closed.events.last().unwrap(); - assert_eq!(receipt.author.as_deref(), Some("workspace-user")); - assert_eq!(receipt.from.as_deref(), Some("planning")); + let corrected = backend.show(reference.id.clone().into()).unwrap(); + assert_eq!(corrected.case_history.len(), 2); + assert_eq!(corrected.case_history[0], closed.case_history[0]); assert_eq!( - receipt.attributes.get("operation_key"), - Some(&"guarded-close".to_string()) + corrected.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Other("Answer superseded by user decision".into()) ); - let reopen = json!({ - "operation_key":"reopen","expected_content_digest":ticket::ticket_content_digest(&closed), - "expected_state":"closed","state":"planning","reason":"A new question arrived" - }); - let response = app - .clone() - .oneshot( - Request::builder() - .method("POST") - .uri(format!("{path}/workflow-state")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(reopen.to_string())) - .unwrap(), - ) - .await + assert_eq!( + corrected.closure_operation_key.as_deref(), + Some("correct-closure") + ); + let detail: TicketDetail = + serde_json::from_value(get_json(app.clone(), &detail_path).await).unwrap(); + assert_eq!(detail.current_closure, corrected.current_closure); + assert_eq!(detail.closure_history, corrected.case_history); + assert_eq!( + detail.closure_operation_key, + corrected.closure_operation_key + ); + assert!(detail.legacy_handoff.is_none()); + assert!(detail.merge_requests.is_empty()); + + // Same state and content are insufficient after correcting a closure. + let stale_correction = case_request( + &closed, + "stale-correction", + json!({"action": "correct_closure", "reason": "duplicate"}), + ); + request_json( + app.clone(), + "POST", + &path, + Some(stale_correction), + StatusCode::CONFLICT, + ) + .await; + let reopen = case_request(&corrected, "reopen", json!({"action": "reopen"})); + request_json( + app.clone(), + "POST", + &path, + Some(reopen.clone()), + StatusCode::OK, + ) + .await; + let reopened = backend.show(reference.id.clone().into()).unwrap(); + assert_eq!(reopened.meta.workflow_state, ticket::TicketState::Open); + assert!(reopened.current_closure.is_none()); + assert_eq!( + reopened.closure_operation_key, + corrected.closure_operation_key + ); + assert_eq!(reopened.case_history.len(), 3); + assert_eq!(ticket::ticket_content_digest(&reopened), digest); + + // Exact replay returns current authority, not the old closed response. + for replay in [close.clone(), correction, reopen] { + let result = request_json(app.clone(), "POST", &path, Some(replay), StatusCode::OK).await; + let summary: server_api::TicketSummary = serde_json::from_value(result).unwrap(); + assert_eq!(summary.0.workflow_state, ticket::TicketState::Open); + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), reopened); + } + let mut changed_replay = close.clone(); + changed_replay["explanation"] = json!("Different judgment under the same operation key"); + request_json( + app.clone(), + "POST", + &path, + Some(changed_replay), + StatusCode::CONFLICT, + ) + .await; + let mut aba_close = close; + aba_close["operation_key"] = json!("stale-open-close"); + request_json( + app.clone(), + "POST", + &path, + Some(aba_close), + StatusCode::CONFLICT, + ) + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), reopened); + let second_close = case_request( + &reopened, + "second-close", + json!({"action": "close", "reason": "not_planned"}), + ); + request_json( + app.clone(), + "POST", + &path, + Some(second_close), + StatusCode::OK, + ) + .await; + let final_ticket = backend.show(reference.id.into()).unwrap(); + assert_eq!( + final_ticket.meta.workflow_state, + ticket::TicketState::Closed + ); + assert_eq!(final_ticket.case_history.len(), 4); + assert_eq!(final_ticket.case_history[..3], reopened.case_history); + assert_eq!( + final_ticket.closure_operation_key.as_deref(), + Some("second-close") + ); + assert_eq!(ticket::ticket_content_digest(&final_ticket), digest); +} + +#[tokio::test] +async fn case_route_rejects_stale_content_state_and_invalid_actions_without_audit_side_effects() { + let temp = tempfile::tempdir().unwrap(); + let api = test_api_with_remote_repository(temp.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let reference = backend + .create(ticket::NewTicket::new("CAS decision")) .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); - // Exact replay of the old close is a receipt lookup, not another close. - let response = app - .oneshot( - Request::builder() - .method("POST") - .uri(format!("{path}/workflow/close")) - .header(CONTENT_TYPE, "application/json") - .body(Body::from(close.to_string())) - .unwrap(), + let before = backend.show(reference.id.clone().into()).unwrap(); + let app = build_inner_router(api); + let path = format!( + "/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case", + reference.id + ); + let close = case_request( + &before, + "close", + json!({"action": "close", "reason": "unknown"}), + ); + for (field, value) in [ + ("expected_content_digest", json!("stale-content")), + ("expected_state", json!("closed")), + ("action", json!({"action": "reopen"})), + ( + "action", + json!({"action": "correct_closure", "reason": "duplicate"}), + ), + ] { + let mut invalid = close.clone(); + invalid[field] = value; + request_json( + app.clone(), + "POST", + &path, + Some(invalid), + StatusCode::CONFLICT, ) - .await - .unwrap(); - assert_eq!(response.status(), StatusCode::NO_CONTENT); - let reopened = backend.show(reference.id.into()).unwrap(); - assert_eq!(reopened.meta.workflow_state, TicketWorkflowState::Planning); - assert_eq!(reopened.events.len(), closed.events.len() + 1); + .await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before); + } + request_json(app.clone(), "POST", &path, Some(close), StatusCode::OK).await; + let closed = backend.show(reference.id.clone().into()).unwrap(); + let duplicate_close = case_request( + &closed, + "duplicate-close", + json!({"action": "close", "reason": "completed"}), + ); + request_json( + app, + "POST", + &path, + Some(duplicate_close), + StatusCode::CONFLICT, + ) + .await; + assert_eq!(backend.show(reference.id.into()).unwrap(), closed); } diff --git a/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs index 3e8a47a1b..ae3340877 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_claim_tests.rs @@ -42,17 +42,7 @@ async fn explicit_existing_worker_claim_accepts_no_resources_and_preserves_assig }, ) .unwrap(); - backend - .set_workflow_state( - fixture.ticket_id.clone().into(), - TicketStateChange::new( - "ready", - "planning", - "Explicit investigation", - "No repository resources required", - ), - ) - .unwrap(); + let before = backend.show(fixture.ticket_id.clone().into()).unwrap(); let request = manual_worker_assignment_request(&fixture, "claim-without-resources"); assert!(request.workdir_bindings.is_empty()); let Json(first) = claim(&fixture, request.clone()).await.unwrap(); @@ -67,8 +57,16 @@ async fn explicit_existing_worker_claim_accepts_no_resources_and_preserves_assig .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - "inprogress" + .state + .as_str(), + "open" + ); + let after = backend.show(fixture.ticket_id.clone().into()).unwrap(); + assert_eq!(after.meta.workflow_state, before.meta.workflow_state); + assert_eq!(after.case_history, before.case_history); + assert_eq!( + ticket::ticket_content_digest(&after), + ticket::ticket_content_digest(&before) ); assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1); assert!( @@ -282,8 +280,9 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - "ready" + .state + .as_str(), + "open" ); assert!( fixture @@ -313,8 +312,9 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - "inprogress" + .state + .as_str(), + "open" ); assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 3); assert_eq!( @@ -435,8 +435,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits .is_empty() ); let backend = browser_ticket_backend(&api).unwrap(); - let mut input = ticket::NewTicket::new("Manual investigation without resources"); - input.workflow_state = Some(TicketWorkflowState::Ready); + let input = ticket::NewTicket::new("Manual investigation without resources"); let ticket_id = backend.create(input).unwrap().id; backend .edit_item( @@ -447,17 +446,6 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits }, ) .unwrap(); - backend - .set_workflow_state( - ticket_id.clone().into(), - TicketStateChange::new( - "ready", - "planning", - "Explicit investigation", - "No repository resources required", - ), - ) - .unwrap(); let admitted_content_digest = api.authority.ticket(&ticket_id).unwrap().content_digest; let request = server_api::SetTicketRoleAssignmentRequest { operation_id: "claim-runtime-reconciliation".into(), @@ -503,7 +491,10 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits assert!(pending.assignment_id.is_none()); assert_eq!(pending.worker, Some(worker.clone())); assert!(pending.request_fingerprint.is_some()); - assert_eq!(api.authority.ticket(&ticket_id).unwrap().state, "planning"); + assert_eq!( + api.authority.ticket(&ticket_id).unwrap().state.as_str(), + "open" + ); assert!( api.store .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id) @@ -601,8 +592,8 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits ); assert_eq!(committed.request_fingerprint, pending.request_fingerprint); assert_eq!( - api.authority.ticket(&ticket_id).unwrap().state, - "inprogress" + api.authority.ticket(&ticket_id).unwrap().state.as_str(), + "open" ); assert_eq!( api.store diff --git a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs index 0a267d1b7..474e37895 100644 --- a/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs +++ b/crates/workspace-server/src/server/tests/ticket_worker_launch_tests.rs @@ -288,17 +288,36 @@ impl LaunchFixture { fixture } - fn ticket(&self, state: TicketWorkflowState) -> String { - let mut input = - ticket::NewTicket::new(format!("Explicit Worker launch from {}", state.as_str())); - input.workflow_state = Some(state); + fn ticket(&self) -> String { browser_ticket_backend(&self.api) .unwrap() - .create(input) + .create(ticket::NewTicket::new( + "Explicit Worker launch on an open case", + )) .unwrap() .id } + fn case_action(&self, ticket_id: &str, operation_key: &str, action: ticket::TicketCaseAction) { + let backend = browser_ticket_backend(&self.api).unwrap(); + let before = backend.show(ticket_id.into()).unwrap(); + backend + .update_case( + ticket_id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&before), + expected_state: before.meta.workflow_state, + expected_closure_operation_key: before.closure_operation_key, + operation_key: operation_key.into(), + action, + explanation: "Explicit fixture case judgment".into(), + references: Vec::new(), + author: Some("fixture".into()), + }, + ) + .unwrap(); + } + fn payload(&self, ticket_id: &str, operation_id: &str, profile: &str, flow: bool) -> Value { self.backend.publish_profile(&self.api, profile); let mut segments = vec![ @@ -382,24 +401,40 @@ impl LaunchFixture { Some(current.assignment_id.as_str()) ); assert_eq!( - self.api.authority.ticket(ticket_id).unwrap().state, - "inprogress" + self.api.authority.ticket(ticket_id).unwrap().state.as_str(), + "open" ); current } } #[tokio::test(flavor = "multi_thread")] -async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_or_orchestrator() { +async fn embedded_explicit_worker_starts_open_cases_without_phase_transitions_or_queue_requirement() +{ let fixture = LaunchFixture::new(false).await; - for state in [ - TicketWorkflowState::Planning, - TicketWorkflowState::Ready, - TicketWorkflowState::Queued, - TicketWorkflowState::InProgress, - ] { - let ticket_id = fixture.ticket(state); - let operation_id = format!("explicit-{}", state.as_str()); + for context in ["unqueued", "queued", "reopened"] { + let ticket_id = fixture.ticket(); + let backend = browser_ticket_backend(&fixture.api).unwrap(); + if context == "queued" { + backend + .queue_ready(ticket_id.clone().into(), "fixture") + .unwrap(); + } else if context == "reopened" { + fixture.case_action( + &ticket_id, + "fixture-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + fixture.case_action( + &ticket_id, + "fixture-reopen", + ticket::TicketCaseAction::Reopen, + ); + } + let before = backend.show(ticket_id.clone().into()).unwrap(); + let operation_id = format!("explicit-{context}"); assert!( fixture .api @@ -414,8 +449,16 @@ async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_ ); let payload = fixture.payload(&ticket_id, &operation_id, "builtin:companion", false); let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; - assert_eq!(status, StatusCode::OK, "{}: {body}", state.as_str()); + assert_eq!(status, StatusCode::OK, "{context}: {body}"); let assignment = fixture.assert_assigned(&ticket_id, &operation_id, &body); + let after = backend.show(ticket_id.clone().into()).unwrap(); + assert_eq!(after.meta.workflow_state, before.meta.workflow_state); + assert_eq!(after.case_history, before.case_history); + assert_eq!(after.closure_operation_key, before.closure_operation_key); + assert_eq!( + ticket::ticket_content_digest(&after), + ticket::ticket_content_digest(&before) + ); assert_eq!(body["worker"]["profile"], "builtin:companion", "{body}"); let creates = fixture.backend.creates_for(&assignment.worker.worker_id); assert_eq!(creates.len(), 1); @@ -434,7 +477,7 @@ async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_ async fn real_runtime_http_worker_launch_preserves_optional_flow_profile_submit_and_assignment() { let fixture = LaunchFixture::new(true).await; for (profile, flow) in [("builtin:intake", false), ("builtin:coder", true)] { - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let operation_id = format!("real-http-{flow}"); let payload = fixture.payload(&ticket_id, &operation_id, profile, flow); let expected_submit = payload["initial_submit"].clone(); @@ -510,7 +553,7 @@ async fn worker_launch_uses_published_project_profile_and_workspace_default_on_r .commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate) .unwrap(); for explicit in [true, false] { - let ticket_id = fixture.ticket(TicketWorkflowState::Ready); + let ticket_id = fixture.ticket(); let operation_id = format!("project-profile-{explicit}"); let mut payload = fixture.payload(&ticket_id, &operation_id, "project:research", false); if !explicit { @@ -532,21 +575,35 @@ async fn worker_launch_uses_published_project_profile_and_workspace_default_on_r } #[tokio::test(flavor = "multi_thread")] -async fn completed_or_closed_ticket_launch_rejects_before_runtime_or_assignment_reservation() { +async fn closed_ticket_launch_rejects_before_runtime_or_assignment_reservation() { let fixture = LaunchFixture::new(false).await; - for state in [TicketWorkflowState::Done, TicketWorkflowState::Closed] { - let ticket_id = fixture.ticket(state); - let operation_id = format!("terminal-{}", state.as_str()); + for (label, reason) in [ + ("completed", ticket::TicketClosureReason::Completed), + ("not-planned", ticket::TicketClosureReason::NotPlanned), + ] { + let ticket_id = fixture.ticket(); + fixture.case_action( + &ticket_id, + "fixture-close", + ticket::TicketCaseAction::Close { reason }, + ); + let operation_id = format!("terminal-{label}"); let payload = fixture.payload(&ticket_id, &operation_id, "builtin:companion", false); let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; assert_eq!(status, StatusCode::CONFLICT, "{body}"); assert!( - body["message"].as_str().unwrap().contains(state.as_str()), + body["message"].as_str().unwrap().contains("closed"), "{body}" ); assert_eq!( - fixture.api.authority.ticket(&ticket_id).unwrap().state, - state.as_str() + fixture + .api + .authority + .ticket(&ticket_id) + .unwrap() + .state + .as_str(), + "closed" ); assert!( fixture @@ -572,7 +629,7 @@ async fn completed_or_closed_ticket_launch_rejects_before_runtime_or_assignment_ #[tokio::test(flavor = "multi_thread", worker_threads = 4)] async fn concurrent_exact_launch_replays_keep_one_worker_and_committed_assignment() { let fixture = Arc::new(LaunchFixture::new(false).await); - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let payload = fixture.payload(&ticket_id, "concurrent-launch", "builtin:companion", false); let barrier = Arc::new(tokio::sync::Barrier::new(5)); let mut tasks = Vec::new(); @@ -617,7 +674,7 @@ async fn concurrent_exact_launch_replays_keep_one_worker_and_committed_assignmen #[tokio::test(flavor = "multi_thread")] async fn undispatched_launch_rejection_settles_claim_and_allows_corrected_operation() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let mut rejected = fixture.payload(&ticket_id, "invalid-resources", "builtin:companion", false); rejected["workdir_attachments"] = json!([{"alias":"checkout","working_directory_id":"missing-workdir"}]); @@ -650,7 +707,7 @@ async fn undispatched_launch_rejection_settles_claim_and_allows_corrected_operat #[tokio::test(flavor = "multi_thread")] async fn worker_launch_retry_reuses_assignment_and_does_not_repeat_initial_submit() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Ready); + let ticket_id = fixture.ticket(); let payload = fixture.payload(&ticket_id, "worker-retry", "builtin:companion", false); let (status, first) = fixture.post(TEST_WORKSPACE_ID, payload.clone()).await; assert_eq!(status, StatusCode::OK, "{first}"); @@ -703,7 +760,7 @@ async fn worker_launch_retry_reuses_assignment_and_does_not_repeat_initial_submi async fn generic_ticket_worker_unknown_input_outcome_preserves_assignment_and_launch_retry_identity() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let payload = fixture.payload( &ticket_id, "worker-unknown-input", @@ -766,7 +823,7 @@ async fn generic_ticket_worker_unknown_input_outcome_preserves_assignment_and_la #[tokio::test(flavor = "multi_thread")] async fn scoped_worker_launch_rejects_foreign_workspace_and_unpublished_profile_with_body() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let payload = fixture.payload(&ticket_id, "scope-rejected", "builtin:companion", false); let (status, body) = fixture .post("019d0000-0000-7000-8000-0000000000bb", payload.clone()) @@ -925,10 +982,10 @@ async fn read_only_ticket_targets_allow_extra_external_grants_without_widening_c } #[tokio::test(flavor = "multi_thread")] -async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_and_preserves_planning() +async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_and_preserves_open_case() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); rusqlite::Connection::open(&fixture.api.config.database_path) .unwrap() .execute_batch( @@ -987,8 +1044,14 @@ async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_an .is_none() ); assert_eq!( - fixture.api.authority.ticket(&ticket_id).unwrap().state, - "planning" + fixture + .api + .authority + .ticket(&ticket_id) + .unwrap() + .state + .as_str(), + "open" ); let counts: (i64, i64) = fixture.api.config_store.with_conn(|conn| Ok(( conn.query_row("SELECT COUNT(*) FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", rusqlite::params![TEST_WORKSPACE_ID, fixture.runtime_id, worker_id], |row| row.get(0))?, @@ -1000,7 +1063,7 @@ async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_an #[tokio::test(flavor = "multi_thread")] async fn choosing_reviewer_profile_does_not_register_a_reviewer_child_or_grant_mr_review() { let fixture = LaunchFixture::new(false).await; - let ticket_id = fixture.ticket(TicketWorkflowState::Planning); + let ticket_id = fixture.ticket(); let payload = fixture.payload(&ticket_id, "ordinary-reviewer", "builtin:reviewer", false); let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; assert_eq!(status, StatusCode::OK, "{body}"); @@ -1236,7 +1299,110 @@ async fn uncertain_launch_replay_after_target_edit_keeps_claim_and_blocks_compet .authority .ticket(&fixture.ticket_id) .unwrap() - .state, - "ready" + .state + .as_str(), + "open" + ); +} + +// Direct policy composition is intentional: this protects routing interest, +// independently of the transport/Runtime launch assertions above. +#[tokio::test(flavor = "multi_thread")] +async fn orchestrator_interest_requires_queue_receipt_or_unfinished_work_not_just_open_state() { + let fixture = LaunchFixture::new(false).await; + let backend = browser_ticket_backend(&fixture.api).unwrap(); + let queued_id = fixture.ticket(); + assign_test_orchestrator(&fixture.api, &queued_id); + assert!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open") + .unwrap() + .is_none() + ); + backend + .queue_ready(queued_id.clone().into(), "fixture") + .unwrap(); + assert!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open") + .unwrap() + .is_some() + ); + fixture.case_action( + &queued_id, + "queue-case-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + ); + assert!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "closed") + .unwrap() + .is_none() + ); + fixture.case_action( + &queued_id, + "queue-case-reopen", + ticket::TicketCaseAction::Reopen, + ); + assert!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open") + .unwrap() + .is_none() + ); + + let active_id = fixture.ticket(); + assign_test_orchestrator(&fixture.api, &active_id); + let payload = fixture.payload( + &active_id, + "routing-active-work", + "builtin:companion", + false, + ); + let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await; + assert_eq!(status, StatusCode::OK, "{body}"); + fixture.assert_assigned(&active_id, "routing-active-work", &body); + let active = backend.show(active_id.clone().into()).unwrap(); + assert!( + active + .events + .iter() + .all(|event| !event.attributes.contains_key("queue_root_ticket")) + ); + assert!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &active_id, "open") + .unwrap() + .is_some() + ); + fixture.case_action( + &active_id, + "active-case-close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + fixture.case_action( + &active_id, + "active-case-reopen", + ticket::TicketCaseAction::Reopen, + ); + assert!( + fixture + .api + .store + .get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &active_id) + .unwrap() + .is_some() + ); + assert!( + fixture + .api + .store + .get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &active_id) + .unwrap() + .is_none() + ); + assert!( + orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &active_id, "open") + .unwrap() + .is_none() ); } diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 3db49cc5d..a3f20242c 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -28,7 +28,7 @@ use crate::workspace_deletion::WorkspaceDeletionStore; use crate::{Error, Result}; const OLDEST_SCHEMA_VERSION: i64 = 50; -const LATEST_SCHEMA_VERSION: i64 = 89; +const LATEST_SCHEMA_VERSION: i64 = 90; const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants"; const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs"; const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline"; @@ -281,6 +281,11 @@ const MIGRATIONS: &[Migration] = &[ name: "descriptive legacy evidence columns", apply: migrate_evidence_column_names_v88_to_v89, }, + Migration { + version: 90, + name: "Ticket open/closed cases and retained routing evidence", + apply: migrate_ticket_cases_v89_to_v90, + }, ]; #[derive(Clone, Copy)] @@ -7935,10 +7940,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { self.with_conn(|conn| { let operation_id: Option = conn .query_row( - "SELECT operation_id FROM ticket_assignment_operations - WHERE workspace_id=?1 AND runtime_id=?2 AND worker_id=?3 - AND claim_state='pending' AND action IN ('assign','reassign') - AND assignment_id IS NULL ORDER BY created_at, operation_id LIMIT 1", + "SELECT claim.operation_id FROM ticket_assignment_operations AS claim + JOIN typed_tickets AS ticket ON ticket.workspace_id=claim.workspace_id + AND ticket.ticket_id=claim.ticket_id + WHERE claim.workspace_id=?1 AND claim.runtime_id=?2 AND claim.worker_id=?3 + AND ticket.workflow_state='open' + AND claim.claim_state='pending' AND claim.action IN ('assign','reassign') + AND claim.assignment_id IS NULL ORDER BY claim.created_at, claim.operation_id LIMIT 1", params![workspace_id, worker.runtime_id, worker.worker_id], |row| row.get(0), ) @@ -8036,10 +8044,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![workspace_id, ticket_id], |row| row.get(0), )?; - if !matches!( - state.as_str(), - "planning" | "ready" | "queued" | "inprogress" - ) { + if state != "open" { return Err(Error::TicketAssignmentConflict(format!( "Ticket `{ticket_id}` cannot claim work in state `{state}`" ))); @@ -8094,6 +8099,11 @@ impl ControlPlaneStore for SqliteWorkspaceStore { || receipt.worker.as_ref().is_some_and(|w| w.worker_id != worker_id) { return Err(Error::TicketAssignmentConflict(format!("assignment operation {operation_id} cannot bind Worker {worker_id}"))); } + let open: bool = tx.query_row( + "SELECT EXISTS(SELECT 1 FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2 AND workflow_state='open')", + params![workspace_id, receipt.ticket_id], |row| row.get(0), + )?; + if !open { return Err(Error::TicketAssignmentConflict("closed Ticket cannot bind pending work".into())); } let runtime_id = receipt.runtime_id.as_deref().ok_or_else(|| Error::TicketAssignmentConflict("pending Worker claim has no Runtime".into()))?; let worker = RuntimeWorkerRef::new(runtime_id, worker_id); ensure_no_competing_ticket_claim(&tx, workspace_id, &receipt.ticket_id, Some(&worker), operation_id)?; @@ -8589,9 +8599,9 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![record.workspace_id, record.ticket_id], |row| row.get(0), )?; - if !matches!(state.as_str(), "planning" | "ready" | "queued" | "inprogress") { + if state != "open" { return Err(Error::TicketAssignmentConflict(format!( - "manual Worker assignment requires planning, ready, queued, or inprogress Ticket; current state is `{state}`" + "manual Worker assignment requires an open Ticket; current state is `{state}`" ))); } let unresolved_blockers: i64 = tx.query_row( @@ -8608,7 +8618,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { (relation.ticket_id = ?2 AND relation.kind = 'depends_on') OR (relation.target = ?2 AND relation.kind = 'blocks') ) - AND related.workflow_state NOT IN ('done', 'closed')", + AND related.workflow_state = 'open'", params![record.workspace_id, record.ticket_id], |row| row.get(0), )?; @@ -8692,10 +8702,10 @@ impl ControlPlaneStore for SqliteWorkspaceStore { "INSERT INTO typed_ticket_events ( workspace_id, ticket_id, event_index, kind, author, at, from_state, to_state, reason, state_field, heading, body - ) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5, ?6, 'inprogress', - 'manual Worker assignment accepted', 'state', 'State changed', '')", + ) VALUES (?1, ?2, ?3, 'assignment', ?4, ?5, NULL, NULL, + 'manual Worker assignment accepted', NULL, 'Worker assigned', '')", params![record.workspace_id, record.ticket_id, event_index, - record.assigned_by, record.assigned_at, state], + record.assigned_by, record.assigned_at], )?; for (key, value) in [ ("event_id", event_id), @@ -8711,17 +8721,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore { params![record.workspace_id, record.ticket_id, event_index, key, value], )?; } - let updated = tx.execute( - "UPDATE typed_tickets SET workflow_state = 'inprogress', - workflow_state_explicit = 1, updated_at = ?3 - WHERE workspace_id = ?1 AND ticket_id = ?2 AND workflow_state = ?4", - params![record.workspace_id, record.ticket_id, record.assigned_at, state], - )?; - if updated != 1 { - return Err(Error::TicketAssignmentConflict( - "Ticket state changed during manual Worker assignment".to_string(), - )); - } + // Assignment claims do not change the Ticket case state. tx.commit()?; Ok(record.clone()) }) @@ -8773,17 +8773,6 @@ impl ControlPlaneStore for SqliteWorkspaceStore { ) -> Result { self.with_conn_mut(|conn| { let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; - let state: String = tx.query_row( - "SELECT workflow_state FROM typed_tickets - WHERE workspace_id = ?1 AND ticket_id = ?2", - params![workspace_id, ticket_id], - |row| row.get(0), - )?; - if !matches!(state.as_str(), "inprogress" | "ready") { - return Err(Error::TicketAssignmentConflict(format!( - "implementation cancellation requires an inprogress Ticket; current state is `{state}`" - ))); - } let cleared = clear_current_ticket_role_assignment_in_tx( &tx, workspace_id, @@ -8800,49 +8789,13 @@ impl ControlPlaneStore for SqliteWorkspaceStore { if !cleared { return Ok(false); } - if state == "inprogress" { - let event_index: i64 = tx.query_row( - "SELECT COALESCE(MAX(event_index), -1) + 1 FROM typed_ticket_events - WHERE workspace_id = ?1 AND ticket_id = ?2", - params![workspace_id, ticket_id], - |row| row.get(0), - )?; - tx.execute( - "INSERT INTO typed_ticket_events ( - workspace_id, ticket_id, event_index, kind, author, at, - from_state, to_state, reason, state_field, heading, body - ) VALUES (?1, ?2, ?3, 'state_changed', ?4, ?5, - 'inprogress', 'ready', ?6, 'state', - 'Implementation cancelled', '')", - params![workspace_id, ticket_id, event_index, actor, occurred_at, reason], - )?; - for (key, value) in [ - ("event_id", state_event_id), - ("assignment_id", assignment_id), - ("assignment_role", "worker"), - ("operation_id", operation_id), - ] { - tx.execute( - "INSERT INTO typed_ticket_event_attributes ( - workspace_id, ticket_id, event_index, key, value - ) VALUES (?1, ?2, ?3, ?4, ?5)", - params![workspace_id, ticket_id, event_index, key, value], - )?; - } - let updated = tx.execute( - "UPDATE typed_tickets SET workflow_state = 'ready', - workflow_state_explicit = 1, - queued_by = NULL, queued_at = NULL, updated_at = ?3 - WHERE workspace_id = ?1 AND ticket_id = ?2 - AND workflow_state = 'inprogress'", - params![workspace_id, ticket_id, occurred_at], - )?; - if updated != 1 { - return Err(Error::TicketAssignmentConflict( - "Ticket state changed during implementation cancellation".to_string(), - )); - } - } + // Ending work never reopens or rolls back a case judgment. + let _ = state_event_id; // Compatibility slot for the old authority caller. + tx.execute( + "UPDATE typed_tickets SET queued_by = NULL, queued_at = NULL + WHERE workspace_id = ?1 AND ticket_id = ?2", + params![workspace_id, ticket_id], + )?; tx.commit()?; Ok(true) }) @@ -13512,11 +13465,8 @@ fn apply_ticket_targets_and_workdir_capabilities_schema(conn: &Connection) -> Re TICKET_SCHEMA_BASELINE_NAME ], )?; - ticket::verify_sqlite_ticket_schema(db).map_err(|error| { - Error::Store(format!( - "schema-66 Ticket schema verification failed: {error}" - )) - })?; + // This retained step composes the historical Ticket schema 7. + // Current-domain verification follows the shared case migration at schema 90. let foreign_key_violations = db.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { @@ -13579,7 +13529,72 @@ fn read_workspace_config_grant( }) } +fn install_ticket_case_assignment_work_schema(conn: &Connection) -> Result<()> { + let (_, sql) = include_str!("latest_schema.sql") + .split_once("-- Ticket case assignment work constraints (schema 90).") + .ok_or_else(|| Error::Store("case assignment schema resource is missing".into()))?; + conn.execute_batch(sql)?; + Ok(()) +} + +fn migrate_ticket_cases_v89_to_v90(conn: &Connection) -> Result<()> { + if current_schema_version(conn)? != 89 { + return Err(Error::Store( + "Ticket case migration requires schema 89".into(), + )); + } + // The shared Ticket migration owns the six-state snapshot, unknown legacy + // closure reasons and immutable case history. It opens its own transaction. + // Leave the Workspace marker at 89 until our composed constraints commit; + // a failed second phase is resumable through the idempotent domain migrator. + ticket::migrate_sqlite_ticket_schema(conn) + .map_err(|error| Error::Store(format!("Ticket case migration failed: {error}")))?; + let invalid: i64 = conn.query_row( + "SELECT COUNT(*) FROM typed_tickets WHERE workflow_state NOT IN ('open', 'closed')", + [], + |row| row.get(0), + )?; + if invalid != 0 { + return Err(Error::Store( + "shared Ticket migration did not install the open/closed case contract".into(), + )); + } + let tx = conn.unchecked_transaction()?; + install_ticket_case_assignment_work_schema(&tx)?; + tx.execute( + "INSERT OR IGNORE INTO ticket_assignment_work_releases + SELECT current.workspace_id, current.assignment_id, current.updated_at + FROM ticket_current_worker_assignments AS current + JOIN typed_tickets AS ticket ON ticket.workspace_id = current.workspace_id + AND ticket.ticket_id = current.ticket_id + WHERE ticket.workflow_state = 'closed'", + [], + )?; + tx.execute( + "INSERT INTO __yoi_schema_migrations(version, name) VALUES (90, 'Ticket open/closed cases and retained routing evidence')", [], + )?; + verify_workspace_resource_constraints(&tx)?; + let violations: i64 = + tx.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get(0) + })?; + if violations != 0 { + return Err(Error::Store(format!( + "Ticket case migration left {violations} foreign-key violations" + ))); + } + tx.commit()?; + Ok(()) +} + fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { + // The composed Workspace parent FK rebuild must not discard domain-owned + // case guards. Retain their SQL from the shared fresh Ticket schema. + let ticket_parent_triggers = conn + .prepare("SELECT sql FROM sqlite_schema WHERE type = 'trigger' AND tbl_name = 'typed_tickets' ORDER BY name")? + .query_map([], |row| row.get::<_, String>(0))? + .collect::>>()?; + conn.execute_batch("CREATE TABLE worker_restore_intents ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, domain_key TEXT NOT NULL, request_id TEXT NOT NULL, expected_token TEXT NOT NULL, @@ -13656,7 +13671,10 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { )); "#, )?; - conn.execute_batch(include_str!("assignment_work.sql"))?; + install_ticket_case_assignment_work_schema(conn)?; + for sql in ticket_parent_triggers { + conn.execute_batch(&sql)?; + } ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { Error::Store(format!( "fresh Workspace Ticket schema verification failed: {error}" @@ -13715,6 +13733,45 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { } } } + if current_schema_version(conn)? >= 90 { + for (name, predicate) in [ + ( + "ticket_active_worker_assignments", + "ticket.workflow_state = 'open'", + ), + ( + "ticket_terminal_releases_work", + "NEW.workflow_state = 'closed'", + ), + ( + "ticket_terminal_assignment_releases_work_insert", + "workflow_state = 'closed'", + ), + ( + "ticket_terminal_assignment_releases_work_update", + "workflow_state = 'closed'", + ), + ( + "ticket_active_worker_role_insert", + "workflow_state = 'open'", + ), + ( + "ticket_active_worker_role_update", + "workflow_state = 'open'", + ), + ] { + let sql: String = conn.query_row( + "SELECT sql FROM sqlite_schema WHERE name = ?1", + [name], + |row| row.get(0), + )?; + if !sql.contains(predicate) || sql.contains("'done'") { + return Err(Error::Store(format!( + "Ticket case constraint {name} does not match schema 90" + ))); + } + } + } Ok(()) } @@ -15935,7 +15992,7 @@ mod tests { migrate_ticket_targets_and_workdir_capabilities_v65_to_v66(&conn).unwrap(); assert_eq!(current_schema_version(&conn).unwrap(), 66); - ticket::verify_sqlite_ticket_schema(&conn).unwrap(); + // The retained step is Ticket-7; current verification runs after schema 90. assert!(!column_exists(&conn, "typed_tickets", "repository_id").unwrap()); assert!(!column_exists(&conn, "typed_tickets", "ref_selector").unwrap()); assert_eq!( @@ -16246,6 +16303,16 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx // Legacy fixtures compose frozen counter-bearing SQL, never current-schema downgrades. fn create_frozen_schema_v85(conn: &Connection) -> Result<()> { + // Compose the frozen Ticket-7 baseline, not current Ticket case tables. + conn.execute_batch( + "DROP TABLE IF EXISTS typed_ticket_case_metadata; + DROP TABLE IF EXISTS typed_ticket_case_history; + DROP TABLE IF EXISTS typed_ticket_legacy_workflow_snapshot; + DELETE FROM ticket_schema_migrations; + INSERT INTO ticket_schema_migrations(version, name, applied_at) + VALUES(7, 'ticket schema baseline', 'fixture');", + )?; + conn.execute_batch("CREATE TABLE worker_restore_intents ( workspace_id TEXT NOT NULL, runtime_id TEXT NOT NULL, worker_id TEXT NOT NULL, domain_key TEXT NOT NULL, request_id TEXT NOT NULL, expected_token TEXT NOT NULL, @@ -16323,11 +16390,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx "#, )?; conn.execute_batch(include_str!("assignment_work.sql"))?; - ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { - Error::Store(format!( - "fresh Workspace Ticket schema verification failed: {error}" - )) - })?; + // Verification uses current schema 9 only after the real retained chain. Ok(()) } @@ -16650,6 +16713,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx version: 89, name: "descriptive legacy evidence columns".to_string() }, + WorkspaceSchemaMigrationStep { + version: 90, + name: "Ticket open/closed cases and retained routing evidence".to_string() + }, ] ); @@ -16749,6 +16816,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (87, "Repository SSH key identities and encrypted operation objects".to_string()), (88, "Runtime binding identities and retention content contracts".to_string()), (89, "descriptive legacy evidence columns".to_string()), +(90, "Ticket open/closed cases and retained routing evidence".to_string()), ] ); assert!(!table_exists(conn, "trusted_runtime_records")?); @@ -19257,6 +19325,436 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); } + // SQLite boundary: a genuine retained Workspace-89 / Ticket-7 fixture, + // not a new database with a lower version marker. + fn legacy_case_workspace() -> (tempfile::TempDir, std::path::PathBuf) { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("cases.db"); + prepare_retained_schema(&path, 89); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) + VALUES('case-owner','user','case-owner','Owner','1','1'); + INSERT INTO workspaces(workspace_id,owner_account_id,display_name,state,created_at,updated_at) + VALUES('case-workspace','case-owner','Cases','active','1','1'); + INSERT INTO repositories(workspace_id,repository_id,repository_key,kind,provider,uri,default_ref,created_at,updated_at,source_kind,source_uri,source_fingerprint,observed_status) + VALUES('case-workspace','case-repo','main','git','git','/fixture','develop','1','1','local_path','/fixture','sha256:fixture','unverified');").unwrap(); + (dir, path) + } + + fn stored_fixture_rows(conn: &Connection, table: &str) -> Vec> { + let mut stmt = conn + .prepare(&format!("SELECT * FROM {table} ORDER BY rowid")) + .unwrap(); + let columns = stmt.column_count(); + stmt.query_map([], |row| (0..columns).map(|i| row.get(i)).collect()) + .unwrap() + .collect::>() + .unwrap() + } + + #[test] + fn schema_v89_cases_preserve_all_six_states_evidence_and_responsibility_without_starting_workers() + { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + for has_mr in [false, true] { + let id = format!("{state}-{has_mr}"); + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,queued_by,queued_at,resolution,readiness,assignee,created_at,updated_at) + VALUES('case-workspace',?1,?1,?1,?2,'task','P2','Original requirements',?3,1,?4,?5,'Original closure text','legacy-readiness','legacy-assignee','1','2')", + params![id, if matches!(state,"done"|"closed") { "closed" } else { "open" }, state, + (state=="queued").then_some("queue-author"), (state=="queued").then_some("queue-time")]).unwrap(); + conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) + VALUES('case-workspace','case-runtime',?1,?1,'normal','1','1')", [&id]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,assigned_by,assigned_at) + VALUES('case-workspace',?1,?1,'worker','worker','case-runtime',?1,'original-actor','1')", [&id]).unwrap(); + conn.execute("INSERT INTO ticket_current_worker_assignments(workspace_id,ticket_id,assignment_id,role,principal_kind,runtime_id,worker_id,updated_at) + VALUES('case-workspace',?1,?1,'worker','worker','case-runtime',?1,'1')", [&id]).unwrap(); + conn.execute("INSERT INTO ticket_worker_assignment_events(workspace_id,ticket_id,event_id,assignment_id,role,action,actor,created_at,operation_id) + VALUES('case-workspace',?1,?1,?1,'worker','assigned','original-actor','1',?1)", [&id]).unwrap(); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,runtime_id,worker_id,assignment_id,created_at,request_fingerprint,claim_state) + VALUES('case-workspace',?1,'assign',?1,'worker','case-runtime',?1,?1,'1','original-claim','committed')", [&id]).unwrap(); + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,from_state,to_state,reason,state_field,heading,body) + VALUES('case-workspace',?1,0,'state_changed','original-author','1','planning',?2,'Original decision','state','Original heading','Original thread')", params![id,state]).unwrap(); + conn.execute("INSERT INTO typed_ticket_event_attributes(workspace_id,ticket_id,event_index,key,value) + VALUES('case-workspace',?1,0,'operation_key','original-operation')", [&id]).unwrap(); + conn.execute("INSERT INTO typed_ticket_event_references(workspace_id,ticket_id,event_index,ordinal,kind,target) + VALUES('case-workspace',?1,0,0,'commit','original-ref')", [&id]).unwrap(); + conn.execute("INSERT INTO typed_ticket_artifacts(workspace_id,ticket_id,relative_path,content) + VALUES('case-workspace',?1,'original.txt',?2)", params![id,b"original artifact".as_slice()]).unwrap(); + if has_mr { + conn.execute("INSERT INTO typed_ticket_targets(workspace_id,ticket_id,ordinal,repository_key,ref_selector,access) + VALUES('case-workspace',?1,0,'main','develop','read_write')", [&id]).unwrap(); + conn.execute("INSERT INTO merge_requests(workspace_id,merge_request_id,repository_id,state,selector_from,selector_to,created_at,updated_at) + VALUES('case-workspace',?1,'case-repo','merged','work/original','develop','1','2')", [&id]).unwrap(); + conn.execute("INSERT INTO merge_request_ticket_relations(workspace_id,merge_request_id,ticket_id,relation_kind,created_at) + VALUES('case-workspace',?1,?1,'implements','1')", [&id]).unwrap(); + let evidence = merge_request::MergeEvent { + event_id: id.clone(), + sequence: 1, + operation_id: id.clone(), + approval_event_id: "original-approval".into(), + approved_source_ref: "original-source".into(), + target_ref_before: "original-target".into(), + target_ref_after: "original-result".into(), + strategy: merge_request::MergeStrategy::FastForward, + resolution: merge_request::ConflictResolution::None, + merged_by: merge_request::WorkerIdentity { + runtime_id: "original-runtime".into(), + worker_id: "original-orchestrator".into(), + }, + created_at: chrono::DateTime::parse_from_rfc3339("2026-01-01T00:00:00Z") + .unwrap() + .with_timezone(&chrono::Utc), + }; + conn.execute("INSERT INTO merge_request_thread_events(workspace_id,merge_request_id,event_id,sequence,kind,payload_json,operation_id,created_at) + VALUES('case-workspace',?1,?1,1,'merge',?2,?1,'2')", params![id,serde_json::to_string(&evidence).unwrap()]).unwrap(); + } + } + } + conn.execute_batch("INSERT INTO worker_control_grants(workspace_id,grant_id,controller_runtime_id,controller_worker_id,subject_runtime_id,subject_worker_id,relation,origin,permissions_json,operation_id,created_at) + VALUES('case-workspace','original-control','case-runtime','planning-false','case-runtime','closed-true','ticket','user','[\"stop\",\"observe\"]','original-grant','1'); + INSERT INTO worker_restore_intents(workspace_id,runtime_id,worker_id,domain_key,request_id,expected_token,settled) + VALUES('case-workspace','case-runtime','closed-true','original-domain','original-restore','original-token',0); + INSERT INTO workdir_registry(workspace_id,workdir_id,source_kind,runtime_id,repository_id,creation_selector,creation_ref,materialization_status,cleanliness,created_at,updated_at) + VALUES('case-workspace','original-workdir','repository','case-runtime','case-repo','develop','original-ref','present','clean','1','1'); + INSERT INTO worker_workdir_links(workspace_id,runtime_id,worker_id,workdir_id,alias,capabilities,connection_id,linked_at) + VALUES('case-workspace','case-runtime','closed-true','original-workdir','checkout','all','original-connection','1');").unwrap(); + // Queued intent with and without a pending claim must survive, but no + // assignment, Worker create/restore or grant is synthesized by migration. + for id in ["pending-queue", "only-queue"] { + conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,queued_by,queued_at) + VALUES('case-workspace',?1,?1,?1,'open','task','P2','','queued',1,'queue-author','queue-time')", [id]).unwrap(); + } + conn.execute("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) + VALUES('case-workspace','case-runtime','pending-worker','Pending','normal','1','1')", []).unwrap(); + conn.execute("INSERT INTO ticket_assignment_operations(workspace_id,operation_id,action,ticket_id,role,runtime_id,worker_id,created_at,request_fingerprint,claim_state) + VALUES('case-workspace','pending-claim','assign','pending-queue','worker','case-runtime','pending-worker','1','pending-fingerprint','pending')", []).unwrap(); + let unchanged = [ + "typed_ticket_events", + "typed_ticket_event_attributes", + "typed_ticket_event_references", + "typed_ticket_artifacts", + "typed_ticket_targets", + "ticket_worker_assignments", + "ticket_current_worker_assignments", + "ticket_worker_assignment_events", + "ticket_assignment_operations", + "worker_registry", + "worker_create_reservations", + "worker_restore_intents", + "workdir_registry", + "worker_workdir_links", + "worker_control_grants", + "merge_requests", + "merge_request_ticket_relations", + "merge_request_thread_events", + ]; + let before = unchanged.map(|table| stored_fixture_rows(&conn, table)); + drop(conn); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + store + .with_conn(|conn| { + assert_eq!(current_schema_version(conn)?, 90); + for (table, rows) in unchanged.iter().zip(before.iter()) { + assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}"); + } + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_work_releases", + [], + |row| row.get::<_, i64>(0) + )?, + 4 + ); + assert_eq!( + conn.query_row( + "SELECT COUNT(*) FROM ticket_active_worker_assignments", + [], + |row| row.get::<_, i64>(0) + )?, + 8 + ); + assert_eq!( + conn.query_row("SELECT COUNT(*) FROM pragma_foreign_key_check", [], |row| { + row.get::<_, i64>(0) + })?, + 0 + ); + Ok(()) + }) + .unwrap(); + for state in [ + "planning", + "ready", + "queued", + "inprogress", + "done", + "closed", + ] { + for has_mr in [false, true] { + let id = format!("{state}-{has_mr}"); + let ticket = backend.show(id.clone().into()).unwrap(); + assert_eq!(ticket.document.body.as_str(), "Original requirements"); + let snapshot = ticket.legacy_workflow_snapshot.as_ref().unwrap(); + assert_eq!(snapshot.workflow_state, state); + assert_eq!( + snapshot.resolution.as_deref(), + Some("Original closure text") + ); + assert_eq!(ticket.meta.assignee.as_deref(), Some("legacy-assignee")); + if matches!(state, "done" | "closed") { + assert_eq!(ticket.meta.workflow_state, ticket::TicketState::Closed); + assert_eq!( + ticket.current_closure.as_ref().unwrap().reason, + ticket::TicketClosureReason::Unknown, + "MR evidence does not infer fulfillment" + ); + assert_eq!(ticket.case_history.len(), 1); + let reopened = update_test_case( + &backend, + &id, + &format!("reopen-{id}"), + ticket::TicketCaseAction::Reopen, + ); + assert_eq!(reopened.meta.workflow_state, ticket::TicketState::Open); + assert_eq!(reopened.case_history.len(), 2); + assert_eq!(reopened.case_history[0], ticket.case_history[0]); + assert!( + store + .get_active_ticket_worker_assignment("case-workspace", &id) + .unwrap() + .is_none() + ); + assert_eq!( + store + .get_current_ticket_worker_assignment("case-workspace", &id) + .unwrap() + .unwrap() + .assignment_id, + id + ); + assert!( + store + .delete_worker_registry( + "case-workspace", + &RuntimeWorkerRef::new("case-runtime", &id) + ) + .is_ok() + ); + } else { + assert_eq!(ticket.meta.workflow_state, ticket::TicketState::Open); + assert!(ticket.case_history.is_empty()); + assert!( + store + .delete_worker_registry( + "case-workspace", + &RuntimeWorkerRef::new("case-runtime", &id) + ) + .is_err() + ); + } + } + } + assert!( + store + .get_current_ticket_worker_assignment("case-workspace", "only-queue") + .unwrap() + .is_none() + ); + assert!( + store + .get_active_ticket_worker_assignment("case-workspace", "pending-queue") + .unwrap() + .is_none() + ); + let pending = store + .get_ticket_assignment_operation("case-workspace", "pending-claim") + .unwrap() + .unwrap(); + assert_eq!(pending.claim_state, "pending"); + assert_eq!( + pending.request_fingerprint.as_deref(), + Some("pending-fingerprint") + ); + drop(store); + let reopened = SqliteWorkspaceStore::open(&path).unwrap(); + assert!( + reopened + .get_active_ticket_worker_assignment("case-workspace", "done-true") + .unwrap() + .is_none() + ); + assert_eq!( + reopened + .get_ticket_assignment_operation("case-workspace", "pending-claim") + .unwrap() + .unwrap(), + pending + ); + } + + #[test] + fn schema_v90_resumes_after_shared_ticket_migration_without_duplicating_judgments() { + use ticket::TicketBackend; + let (_dir, path) = legacy_case_workspace(); + let conn = Connection::open(&path).unwrap(); + configure_sqlite(&conn).unwrap(); + conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) + VALUES('case-workspace','terminal','terminal','Terminal','closed','task','P2','','done',1); + DROP VIEW ticket_active_worker_assignments; + CREATE TABLE ticket_active_worker_assignments(collision TEXT);").unwrap(); + let error = migrate_ticket_cases_v89_to_v90(&conn).unwrap_err(); + assert!(error.to_string().contains("DROP TABLE")); + assert_eq!(current_schema_version(&conn).unwrap(), 89); + // The shared transaction completed, but Workspace did not advertise a + // composed schema. Its immutable evidence remains the retry authority. + let before = stored_fixture_rows(&conn, "typed_ticket_case_history"); + assert_eq!(before.len(), 1); + conn.execute_batch("DROP TABLE ticket_active_worker_assignments;") + .unwrap(); + migrate_ticket_cases_v89_to_v90(&conn).unwrap(); + assert_eq!(current_schema_version(&conn).unwrap(), 90); + assert_eq!( + stored_fixture_rows(&conn, "typed_ticket_case_history"), + before + ); + drop(conn); + let store = SqliteWorkspaceStore::open(&path).unwrap(); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "case-workspace").unwrap(); + assert_eq!( + backend + .show("terminal".into()) + .unwrap() + .current_closure + .unwrap() + .reason, + ticket::TicketClosureReason::Unknown + ); + assert!( + store + .get_active_ticket_worker_assignment("case-workspace", "terminal") + .unwrap() + .is_none() + ); + } + + #[tokio::test] + async fn claims_and_compensation_do_not_change_case_state_or_resume_closed_work() { + let (dir, store) = manual_worker_start_store().await; + seed_manual_start_ticket(&store, "ticket", "open"); + let record = manual_worker_assignment("ticket", "assignment"); + let fingerprint = manual_ticket_assignment_fingerprint(&record, None).unwrap(); + store + .reserve_ticket_assignment_operation( + "w", + "claim", + "ticket", + "r", + Some("worker"), + &fingerprint, + "1", + ) + .unwrap(); + let backend = + ticket::SqliteTicketBackend::open_verified(dir.path().join("workspace.db"), "w") + .unwrap(); + use ticket::TicketBackend; + assert_eq!( + backend.show("ticket".into()).unwrap().meta.workflow_state, + ticket::TicketState::Open + ); + let closed = update_test_case( + &backend, + "ticket", + "close", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::NotPlanned, + }, + ); + assert!( + store + .get_pending_ticket_assignment_operation_for_worker( + "w", + &RuntimeWorkerRef::new("r", "worker") + ) + .unwrap() + .is_none() + ); + assert!( + store + .bind_ticket_assignment_operation_worker("w", "claim", "worker") + .is_err() + ); + assert!( + store + .start_ticket_with_worker_assignment(&record, "event", "claim", None) + .is_err() + ); + store + .rollback_ticket_assignment_operation("w", "claim") + .unwrap(); + assert_eq!( + backend.show("ticket".into()).unwrap().meta.workflow_state, + ticket::TicketState::Closed + ); + assert_eq!( + backend.show("ticket".into()).unwrap().case_history, + closed.case_history + ); + assert_eq!( + store + .get_ticket_assignment_operation("w", "claim") + .unwrap() + .unwrap() + .claim_state, + "failed" + ); + assert!( + store + .get_current_ticket_worker_assignment("w", "ticket") + .unwrap() + .is_none() + ); + // Explicit reopening also leaves the failed claim receipt inert. + update_test_case( + &backend, + "ticket", + "reopen", + ticket::TicketCaseAction::Reopen, + ); + assert!( + store + .start_ticket_with_worker_assignment(&record, "event", "claim", None) + .is_err() + ); + } + + #[test] + fn schema_v90_case_constraint_verification_rejects_legacy_view_predicates() { + let store = SqliteWorkspaceStore::in_memory().unwrap(); + store.with_conn(|conn| { + conn.execute_batch("DROP VIEW ticket_active_worker_assignments; + CREATE VIEW ticket_active_worker_assignments AS + SELECT current.* FROM ticket_current_worker_assignments AS current + JOIN typed_tickets AS ticket ON ticket.workspace_id=current.workspace_id AND ticket.ticket_id=current.ticket_id + WHERE ticket.workflow_state NOT IN ('done','closed');")?; + assert!(verify_workspace_resource_constraints(conn).unwrap_err().to_string().contains("schema 90")); + Ok(()) + }).unwrap(); + } + // Equivalent to the reported closed T-12 + retained Coder data. The // migration fixture is a real pre-cutover schema, not the live Workspace. #[test] @@ -19370,7 +19868,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx matches!(&blockers[..], [crate::retention::WorkerRemovalBlocker::Hold,crate::retention::WorkerRemovalBlocker::UnfinishedWork { assignment_id,ticket_id }] if assignment_id=="current_assignment" && ticket_id=="closed-ticket") ); conn.execute_batch( - "UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id='closed-ticket'; + "UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id='closed-ticket'; DELETE FROM worker_registry WHERE worker_id='old-coder';", ) .unwrap(); @@ -19428,6 +19926,31 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (dir, store) } + fn update_test_case( + backend: &ticket::SqliteTicketBackend, + id: &str, + operation: &str, + action: ticket::TicketCaseAction, + ) -> ticket::Ticket { + use ticket::TicketBackend; + let current = backend.show(id.into()).unwrap(); + backend + .update_case( + id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(¤t), + expected_state: current.meta.workflow_state, + expected_closure_operation_key: current.closure_operation_key, + operation_key: operation.into(), + action, + explanation: "Store fixture case judgment".into(), + references: vec![], + author: Some("store-fixture-author".into()), + }, + ) + .unwrap() + } + fn seed_manual_start_ticket(store: &SqliteWorkspaceStore, id: &str, state: &str) { store.with_conn(|conn| { conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit,updated_at) @@ -19458,8 +19981,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx async fn pending_claims_serialize_competing_ticket_and_known_worker_reservations() { for (same_ticket, same_operation) in [(true, false), (false, false), (true, true)] { let (dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); - seed_manual_start_ticket(&store, "second", "ready"); + seed_manual_start_ticket(&store, "first", "open"); + seed_manual_start_ticket(&store, "second", "open"); store.with_conn(|conn| { conn.execute_batch("INSERT INTO worker_registry(workspace_id,runtime_id,worker_id,display_name,retention_state,created_at,updated_at) VALUES('w','r','other','Other','normal','1','1');")?; @@ -19530,8 +20053,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn pending_claims_fence_all_assignment_writers_and_worker_binding_until_owner_commits() { let (_dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); - seed_manual_start_ticket(&store, "second", "ready"); + seed_manual_start_ticket(&store, "first", "open"); + seed_manual_start_ticket(&store, "second", "open"); let first = manual_worker_assignment("first", "first-assignment"); let orchestrator = TicketRoleAssignmentRecord { role: TicketAssignmentRole::Orchestrator, @@ -19702,7 +20225,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn manual_claim_consumes_matching_binding_fingerprint_and_replays_committed_receipt() { let (_dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); + seed_manual_start_ticket(&store, "first", "open"); let record = manual_worker_assignment("first", "assignment"); let fingerprint = manual_ticket_assignment_fingerprint(&record, Some("bindings-a")).unwrap(); @@ -19795,7 +20318,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn manual_claim_recovery_survives_reopen_and_fences_stale_item_finalization() { let (dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); + seed_manual_start_ticket(&store, "first", "open"); let record = manual_worker_assignment("first", "assignment"); let fingerprint = manual_ticket_assignment_fingerprint(&record, Some("bindings")).unwrap(); store @@ -19810,7 +20333,13 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ) .unwrap(); let recovery = TicketWorkerClaimRecovery { - content_digest: "first:0".into(), + content_digest: store + .with_conn(|conn| { + ticket::sqlite_ticket_content_digest(conn, "w", "first") + .map_err(|error| Error::Store(error.to_string())) + }) + .unwrap() + .unwrap(), selected: vec![], original_links: vec![], effective_links: vec![], @@ -19834,7 +20363,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx Some(recovery) ); reopened.with_conn(|conn| { - conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('w','first',1,'item_edit','Ticket targets changed')",[])?; Ok(()) + conn.execute_batch("UPDATE typed_tickets SET title='Changed requirements' WHERE workspace_id='w' AND ticket_id='first'; + INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,body) VALUES('w','first',1,'item_edit','Ticket requirements changed');")?; Ok(()) }).unwrap(); assert!( reopened @@ -19862,7 +20392,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx #[tokio::test] async fn manual_claim_failed_receipt_survives_reopen_and_rollback_without_restarting() { let (dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); + seed_manual_start_ticket(&store, "first", "open"); let record = manual_worker_assignment("first", "assignment"); let fingerprint = manual_ticket_assignment_fingerprint(&record, Some("bindings")).unwrap(); store @@ -19937,15 +20467,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } #[tokio::test] - async fn manual_worker_start_accepts_nonterminal_states_with_idempotent_assignment_and_audit() { - for state in [ - "planning", - "ready", - "queued", - "inprogress", - "done", - "closed", - ] { + async fn manual_worker_start_is_case_neutral_with_idempotent_assignment_and_audit() { + for state in ["open", "closed"] { for user_principal in [false, true] { let (_dir, store) = manual_worker_start_store().await; seed_manual_start_ticket(&store, "ticket", state); @@ -19957,7 +20480,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx } let result = store.start_ticket_with_worker_assignment(&record, "event", "operation", None); - if matches!(state, "done" | "closed") { + if state == "closed" { assert!(result.is_err(), "{state}"); } else { assert_eq!(result.unwrap(), record, "{state}"); @@ -19994,12 +20517,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx (SELECT COUNT(*) FROM ticket_worker_assignments), (SELECT COUNT(*) FROM ticket_assignment_operations) FROM typed_tickets WHERE ticket_id='ticket'", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?; - if matches!(state, "done" | "closed") { + if state == "closed" { assert_eq!((persisted.as_str(), events, assignments, operations), (state,0,0,0)); } else { - assert_eq!((persisted.as_str(), events, assignments, operations), ("inprogress",1,1,1)); - let transition: (String,String) = conn.query_row("SELECT from_state,to_state FROM typed_ticket_events", [], |row| Ok((row.get(0)?,row.get(1)?)))?; - assert_eq!(transition, (state.into(), "inprogress".into())); + assert_eq!((persisted.as_str(), events, assignments, operations), ("open",1,1,1)); + let transition: (Option,Option,String) = conn.query_row("SELECT from_state,to_state,kind FROM typed_ticket_events", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?; + assert_eq!(transition, (None, None, "assignment".into())); assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='assignment_role'", [], |row| row.get::<_,String>(0))?, "worker"); } Ok(()) @@ -20012,8 +20535,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx async fn manual_worker_start_rejects_busy_worker_without_mutation_and_requires_new_reopen_identity() { let (_dir, store) = manual_worker_start_store().await; - seed_manual_start_ticket(&store, "first", "planning"); - seed_manual_start_ticket(&store, "second", "queued"); + seed_manual_start_ticket(&store, "first", "open"); + seed_manual_start_ticket(&store, "second", "open"); let first = manual_worker_assignment("first", "first-assignment"); let second = manual_worker_assignment("second", "second-assignment"); store @@ -20037,7 +20560,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx [], |row| row.get::<_, String>(0) )?, - "queued" + "open" ); assert_eq!( conn.query_row( @@ -20049,7 +20572,7 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx ); conn.execute_batch( "UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id='first'; - UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id='first';", + UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id='first';", )?; Ok(()) }) @@ -20152,12 +20675,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .set_current_ticket_worker_assignment(&next, None, "e2", "op2", false) .is_err() ); - backend - .close( - first.id.clone().into(), - ticket::MarkdownText::new("Closed without integration proof"), - ) - .unwrap(); + update_test_case( + &backend, + &first.id, + "close-first", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + ); assert_eq!( store .get_current_ticket_worker_assignment("w", &first.id) @@ -20173,9 +20698,12 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx store .set_current_ticket_worker_assignment(&next, None, "e2", "op2", false) .unwrap(); - // Generic Ticket tools do not expose terminal -> planning. Seed the - // authority state boundary to prove future/reconciled reopen is inert. - store.with_conn(|conn| { conn.execute("UPDATE typed_tickets SET workflow_state='planning' WHERE workspace_id='w' AND ticket_id=?1", [&first.id])?; Ok(()) }).unwrap(); + update_test_case( + &backend, + &first.id, + "reopen-first", + ticket::TicketCaseAction::Reopen, + ); assert!( store .get_active_ticket_worker_assignment("w", &first.id) @@ -20202,9 +20730,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx .is_err(), "another Ticket still occupies the Worker" ); - backend - .close(second.id.clone().into(), ticket::MarkdownText::new("Ended")) - .unwrap(); + update_test_case( + &backend, + &second.id, + "close-second", + ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Unknown, + }, + ); store .set_current_ticket_worker_assignment(&restarted, None, "e3", "op3", false) .unwrap(); @@ -20250,9 +20783,9 @@ INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit ) VALUES - ('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', 'open', 'task', 'normal', '', 'planning', 1), - ('workspace-a', 'ticket-2', 'ticket-2', 'Ticket 2', 'open', 'task', 'normal', '', 'planning', 1), - ('workspace-a', 'ticket-3', 'ticket-3', 'Ticket 3', 'open', 'task', 'normal', '', 'planning', 1); + ('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', 'open', 'task', 'normal', '', 'open', 1), + ('workspace-a', 'ticket-2', 'ticket-2', 'Ticket 2', 'open', 'task', 'normal', '', 'open', 1), + ('workspace-a', 'ticket-3', 'ticket-3', 'Ticket 3', 'open', 'task', 'normal', '', 'open', 1); INSERT INTO worker_registry ( workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at ) VALUES @@ -20563,7 +21096,7 @@ INSERT INTO worker_registry ( ticket::SqliteTicketBackend::open_verified(&db_path, "workspace-role").unwrap(); let mut input = ticket::NewTicket::new("Role assignment"); input.body = ticket::MarkdownText::new("test"); - input.workflow_state = Some(ticket::TicketWorkflowState::Ready); + input.workflow_state = Some(ticket::TicketState::Open); input.targets = vec![ticket::TicketTarget { repository_key: "main".to_string(), ref_selector: Some("develop".to_string()), @@ -20636,7 +21169,7 @@ INSERT INTO worker_registry ( .unwrap() .meta .workflow_state, - ticket::TicketWorkflowState::Ready + ticket::TicketState::Open ); assert!( store @@ -20672,10 +21205,7 @@ INSERT INTO worker_registry ( .unwrap(); assert_eq!(replayed, coder); let ticket = ticket::TicketBackend::show(&backend, ticket.id.clone().into()).unwrap(); - assert_eq!( - ticket.meta.workflow_state, - ticket::TicketWorkflowState::InProgress - ); + assert_eq!(ticket.meta.workflow_state, ticket::TicketState::Open); assert_eq!( ticket .events @@ -20735,7 +21265,7 @@ INSERT INTO worker_registry ( ticket::TicketBackend::show(&backend, ticket.meta.id.clone().into()).unwrap(); assert_eq!( cancelled_ticket.meta.workflow_state, - ticket::TicketWorkflowState::Ready + ticket::TicketState::Open ); assert_eq!( cancelled_ticket @@ -20850,7 +21380,7 @@ INSERT INTO workspace_signing_identities ( INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit -) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', 'planning', 1); +) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', 'open', 1); INSERT INTO worker_registry ( workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at ) VALUES ( @@ -20880,7 +21410,7 @@ INSERT INTO workspace_signing_identities ( INSERT INTO typed_tickets ( workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit -) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', '', 'planning', 1); +) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', '', 'open', 1); INSERT INTO worker_registry ( workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at ) VALUES ( diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index df0250583..9c1f2a327 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -1266,9 +1266,8 @@ mod tests { #[tokio::test] async fn terminal_assignments_release_unfinished_work_guard_but_not_attachment_or_hold() { - for (status, workflow_state) in [("closed", "closed"), ("open", "done")] { - let (store, workdir) = seeded_store().await; - store + let (store, workdir) = seeded_store().await; + store .with_conn(|conn| { conn.execute( "INSERT INTO worker_registry ( @@ -1288,7 +1287,7 @@ mod tests { workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit ) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', - 'task', 'normal', '', 'inprogress', 1)", + 'task', 'normal', '', 'open', 1)", [], )?; conn.execute( @@ -1307,59 +1306,58 @@ mod tests { Ok(()) }) .unwrap(); - let intent = - workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap(); - let operation = store.reserve_workdir_removal_operation(&intent).unwrap(); - let categories = |guards: Vec| { - guards - .into_iter() - .map(|guard| guard.category) - .collect::>() - }; - assert_eq!( - categories(store.workdir_removal_guards(&operation).unwrap()), - vec!["active_attachment", "unfinished_work", "retention_hold"] - ); - store - .with_conn(|conn| { - conn.execute( - "UPDATE typed_tickets SET status=?1, workflow_state=?2 + let intent = workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap(); + let operation = store.reserve_workdir_removal_operation(&intent).unwrap(); + let categories = |guards: Vec| { + guards + .into_iter() + .map(|guard| guard.category) + .collect::>() + }; + assert_eq!( + categories(store.workdir_removal_guards(&operation).unwrap()), + vec!["active_attachment", "unfinished_work", "retention_hold"] + ); + store + .with_conn(|conn| { + conn.execute( + "UPDATE typed_tickets SET status='closed', workflow_state='closed' WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'", - params![status, workflow_state], - )?; - Ok(()) - }) - .unwrap(); - assert_eq!( - categories(store.workdir_removal_guards(&operation).unwrap()), - vec!["active_attachment", "retention_hold"] - ); - store - .with_conn(|conn| { - conn.execute( - "UPDATE typed_tickets SET status='open', workflow_state='planning' + [], + )?; + Ok(()) + }) + .unwrap(); + assert_eq!( + categories(store.workdir_removal_guards(&operation).unwrap()), + vec!["active_attachment", "retention_hold"] + ); + store + .with_conn(|conn| { + conn.execute( + "UPDATE typed_tickets SET status='open', workflow_state='open' WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'", - [], - )?; - Ok(()) - }) - .unwrap(); - assert_eq!( - categories(store.workdir_removal_guards(&operation).unwrap()), - vec!["active_attachment", "retention_hold"], - "reopening must not restore unfinished work on the retained assignment" - ); - assert!(matches!( - store.commit_workdir_removal_removed(&operation), - Err(Error::WorkdirAttachmentConflict(_)) - )); - assert!( - store - .get_workdir_registry("workspace-a", "workdir-a") - .unwrap() - .is_some() - ); + [], + )?; + Ok(()) + }) + .unwrap(); + assert_eq!( + categories(store.workdir_removal_guards(&operation).unwrap()), + vec!["active_attachment", "retention_hold"], + "reopening must not restore unfinished work on the retained assignment" + ); + assert!(matches!( + store.commit_workdir_removal_removed(&operation), + Err(Error::WorkdirAttachmentConflict(_)) + )); + assert!( store + .get_workdir_registry("workspace-a", "workdir-a") + .unwrap() + .is_some() + ); + store .with_conn(|conn| { conn.execute( "UPDATE worker_registry SET retention_state='normal' @@ -1369,49 +1367,63 @@ mod tests { Ok(()) }) .unwrap(); - assert_eq!( - categories(store.workdir_removal_guards(&operation).unwrap()), - vec!["active_attachment"] - ); - assert!(matches!( - store.commit_workdir_removal_removed(&operation), - Err(Error::WorkdirAttachmentConflict(_)) - )); - store - .with_conn(|conn| { - conn.execute( - "UPDATE worker_workdir_links SET unlinked_at='2' + assert_eq!( + categories(store.workdir_removal_guards(&operation).unwrap()), + vec!["active_attachment"] + ); + assert!(matches!( + store.commit_workdir_removal_removed(&operation), + Err(Error::WorkdirAttachmentConflict(_)) + )); + store + .with_conn(|conn| { + conn.execute( + "UPDATE worker_workdir_links SET unlinked_at='2' WHERE workspace_id='workspace-a' AND workdir_id='workdir-a'", - [], - )?; - Ok(()) - }) - .unwrap(); - assert!(store.workdir_removal_guards(&operation).unwrap().is_empty()); - let completed = store.commit_workdir_removal_removed(&operation).unwrap(); - assert_eq!( - completed.disposition, - Some(WorkdirRemovalDisposition::Removed) - ); - assert!( - store - .get_workdir_registry("workspace-a", "workdir-a") - .unwrap() - .is_none() - ); - let retained: i64 = store - .with_conn(|conn| { - conn.query_row( - "SELECT COUNT(*) FROM ticket_current_worker_assignments + [], + )?; + Ok(()) + }) + .unwrap(); + assert!(store.workdir_removal_guards(&operation).unwrap().is_empty()); + let completed = store.commit_workdir_removal_removed(&operation).unwrap(); + assert_eq!( + completed.disposition, + Some(WorkdirRemovalDisposition::Removed) + ); + assert!( + store + .get_workdir_registry("workspace-a", "workdir-a") + .unwrap() + .is_none() + ); + let retained: i64 = store + .with_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM ticket_current_worker_assignments WHERE workspace_id='workspace-a' AND assignment_id='assignment-a'", - [], - |row| row.get(0), - ) - .map_err(Error::from) - }) - .unwrap(); - assert_eq!(retained, 1); - } + [], + |row| row.get(0), + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!(retained, 1); + let released: i64 = store + .with_conn(|conn| { + conn.query_row( + "SELECT COUNT(*) FROM ticket_assignment_work_releases + WHERE workspace_id='workspace-a' AND assignment_id='assignment-a'", + [], + |row| row.get(0), + ) + .map_err(Error::from) + }) + .unwrap(); + assert_eq!( + released, 1, + "reopening and cleanup must preserve the work release" + ); } #[tokio::test] diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index dfc1b70da..922c3334c 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -52,6 +52,9 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "ticket_current_worker_assignments", "ticket_worker_assignment_events", "ticket_worker_assignments", + "typed_ticket_case_history", + "typed_ticket_case_metadata", + "typed_ticket_legacy_workflow_snapshot", "typed_ticket_artifacts", "typed_ticket_event_attributes", "typed_ticket_event_references", @@ -457,6 +460,26 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { } let workspace_id = operation.response.workspace_id.clone(); + // Case judgments and legacy snapshots are immutable under ordinary + // Ticket operations, including Ticket deletion and FK cascades. Only + // this owner-authorized whole-Workspace purge may discard them. + // SQLite transactional DDL holds the writer lock: no concurrent writer + // can observe the suspended guards, and any error rolls back both + // data and schema. Restore the exact schema-owned definitions before + // commit; update guards remain installed throughout. + let mut case_delete_guards = Vec::new(); + for (table, trigger) in [ + ("typed_ticket_case_history", "typed_ticket_case_history_no_delete"), + ("typed_ticket_legacy_workflow_snapshot", "typed_ticket_legacy_workflow_no_delete"), + ] { + let definition: String = tx.query_row( + "SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?1 AND tbl_name=?2", + params![trigger, table], + |row| row.get(0), + )?; + tx.execute_batch(&format!("DROP TRIGGER \"{trigger}\";"))?; + case_delete_guards.push(definition); + } for table in WORKSPACE_DELETION_PURGE_TABLES { tx.execute( &format!("DELETE FROM \"{table}\" WHERE workspace_id = ?1"), @@ -472,6 +495,9 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { "Workspace disappeared before deletion finalized".to_string(), )); } + for definition in case_delete_guards { + tx.execute_batch(&definition)?; + } let now = Utc::now().to_rfc3339(); tx.execute( "UPDATE workspace_deletion_operations @@ -874,8 +900,10 @@ mod tests { use tempfile::tempdir; fn setup() -> (SqliteWorkspaceStore, String, String) { - let dir = tempdir().expect("tempdir"); - let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).expect("store"); + seed_workspaces(SqliteWorkspaceStore::in_memory().expect("store")) + } + + fn seed_workspaces(store: SqliteWorkspaceStore) -> (SqliteWorkspaceStore, String, String) { store.with_conn(|conn| { let now = Utc::now().to_rfc3339(); conn.execute( @@ -931,6 +959,191 @@ mod tests { .expect("purge inventory"); } + #[test] + fn workspace_purge_preserves_other_cases_and_restores_immutability_after_commit_or_rollback() { + use ticket::TicketBackend; + + for inject_failure in [false, true] { + let dir = tempdir().expect("tempdir"); + let path = dir.path().join("server.db"); + let (store, owner, workspace_id) = + seed_workspaces(SqliteWorkspaceStore::open(&path).expect("store")); + for workspace in ["workspace-a", "workspace-b"] { + let backend = ticket::SqliteTicketBackend::open_verified(&path, workspace).unwrap(); + let initial = backend + .create_with_snapshot(ticket::NewTicket::new("Case evidence")) + .unwrap() + .1; + backend + .update_case( + &initial.meta.id, + ticket::TicketCaseUpdate { + expected_content_digest: ticket::ticket_content_digest(&initial), + expected_state: initial.meta.workflow_state, + expected_closure_operation_key: initial.closure_operation_key, + operation_key: "close".into(), + action: ticket::TicketCaseAction::Close { + reason: ticket::TicketClosureReason::Completed, + }, + explanation: "Requested result satisfied".into(), + references: vec![], + author: Some("owner".into()), + }, + ) + .unwrap(); + // Seed only the opaque retained snapshot for the deletion boundary; + // migration and legacy phase interpretation are tested by the store. + let snapshot = ticket::TicketLegacyWorkflowSnapshot { + workflow_state: "closed".into(), + workflow_state_explicit: true, + status: "closed".into(), + queued_by: None, + queued_at: None, + resolution: None, + }; + store + .with_conn(|conn| { + conn.execute( + "INSERT INTO typed_ticket_legacy_workflow_snapshot + (workspace_id,ticket_id,snapshot_json) VALUES (?1,?2,?3)", + params![ + workspace, + initial.meta.id, + serde_json::to_string(&snapshot).unwrap() + ], + )?; + Ok(()) + }) + .unwrap(); + } + let assert_immutable = |workspace: &str| { + store.with_conn(|conn| { + for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot"] { + assert!(conn.execute(&format!("DELETE FROM {table} WHERE workspace_id=?1"), [workspace]).is_err()); + assert!(conn.execute(&format!("UPDATE {table} SET ticket_id=ticket_id WHERE workspace_id=?1"), [workspace]).is_err()); + } + assert!(conn.execute("DELETE FROM typed_tickets WHERE workspace_id=?1", [workspace]).is_err(), + "ordinary Ticket deletion must not cascade away case evidence"); + Ok(()) + }).unwrap(); + }; + assert_immutable("workspace-a"); + assert_immutable("workspace-b"); + let preflight = store + .workspace_deletion_preflight(&owner, &workspace_id) + .unwrap(); + let operation = store + .reserve_workspace_deletion( + &owner, + &workspace_id, + &WorkspaceDeletionRequest { + operation_id: "delete-cases".into(), + expected_workspace_updated_at: preflight.expected_workspace_updated_at, + confirmation: "Alpha".into(), + }, + ) + .unwrap() + .operation; + let tables = [ + "typed_ticket_case_history", + "typed_ticket_case_metadata", + "typed_ticket_legacy_workflow_snapshot", + ]; + let other_before: Vec = store + .with_conn(|conn| { + tables + .iter() + .map(|table| { + conn.query_row( + &format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"), + [], + |row| row.get(row.as_ref().column_count() - 1), + ) + .map_err(Into::into) + }) + .collect() + }) + .unwrap(); + if inject_failure { + store + .with_conn(|conn| { + conn.execute_batch( + "CREATE TRIGGER reject_workspace_purge BEFORE DELETE ON workspaces + BEGIN SELECT RAISE(ABORT, 'injected purge failure'); END;", + )?; + Ok(()) + }) + .unwrap(); + assert!( + store + .finalize_workspace_deletion(&operation.operation_id) + .is_err() + ); + assert_immutable("workspace-a"); + assert_immutable("workspace-b"); + store + .with_conn(|conn| { + for table in tables { + let count: i64 = conn.query_row( + &format!( + "SELECT COUNT(*) FROM {table} WHERE workspace_id='workspace-a'" + ), + [], + |row| row.get(0), + )?; + assert_eq!(count, 1, "rollback must restore {table}"); + } + conn.execute_batch("DROP TRIGGER reject_workspace_purge;")?; + Ok(()) + }) + .unwrap(); + assert_ne!( + store + .workspace_deletion_operation_for_recovery(&operation.operation_id) + .unwrap() + .unwrap() + .state, + WorkspaceDeletionState::Succeeded + ); + } + let completed = store + .finalize_workspace_deletion(&operation.operation_id) + .unwrap(); + assert_eq!(completed.state, WorkspaceDeletionState::Succeeded); + assert_eq!( + store + .finalize_workspace_deletion(&operation.operation_id) + .unwrap(), + completed + ); + assert_immutable("workspace-b"); + store + .with_conn(|conn| { + for (table, before) in tables.iter().zip(&other_before) { + let count: i64 = conn.query_row( + &format!( + "SELECT COUNT(*) FROM {table} WHERE workspace_id='workspace-a'" + ), + [], + |row| row.get(0), + )?; + assert_eq!(count, 0, "purge must remove {table}"); + let after: String = conn.query_row( + &format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"), + [], + |row| row.get(row.as_ref().column_count() - 1), + )?; + assert_eq!( + &after, before, + "other Workspace {table} evidence must remain unchanged" + ); + } + Ok(()) + }) + .unwrap(); + } + } + #[test] fn deletion_is_idempotent_and_removes_workspace_scoped_rows() { let (store, owner, workspace_id) = setup(); diff --git a/crates/yoi/src/ticket_cli.rs b/crates/yoi/src/ticket_cli.rs index 3f108629f..1f53b016c 100644 --- a/crates/yoi/src/ticket_cli.rs +++ b/crates/yoi/src/ticket_cli.rs @@ -4,9 +4,10 @@ use std::path::PathBuf; use client::{BackendWorkspaceProductClient, ResolvedTarget}; use ticket::{ - MarkdownText, NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, - TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, TicketListQuery, TicketListState, - TicketRelationKind, TicketSummary, TicketWorkflowState, + NewTicket, NewTicketEvent, NewTicketRelation, TicketBackend, TicketCaseAction, + TicketCaseUpdate, TicketClosureReason, TicketDoctorSeverity, TicketEventKind, TicketIdOrSlug, + TicketListQuery, TicketListState, TicketReference, TicketRelationKind, TicketState, + TicketSummary, }; const DEFAULT_LIST_LIMIT: usize = 50; @@ -26,8 +27,7 @@ pub enum TicketCommand { List(ListOptions), Show { query: String }, Comment(CommentOptions), - State(StateOptions), - Close(CloseOptions), + Case(CaseOptions), Relation(RelationOptions), Doctor, } @@ -57,26 +57,20 @@ pub struct CommentOptions { pub body: BodySource, } -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum StateTarget { - Planning, - Ready, - Queued, - InProgress, - Done, - Closed, -} - +/// Explicit preconditions are required instead of silently refreshing them from +/// show. Repeating the same argv (and file bytes) submits the exact same request, +/// even after the original action changed the case state or closure key. #[derive(Debug, Clone, PartialEq, Eq)] -pub struct StateOptions { +pub struct CaseOptions { pub query: String, - pub state: StateTarget, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct CloseOptions { - pub query: String, - pub resolution: BodySource, + pub expected_content_digest: String, + pub expected_state: TicketState, + pub expected_closure_operation_key: Option, + pub operation_key: String, + pub action: TicketCaseAction, + pub explanation: BodySource, + pub references: Vec, + pub author: String, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -157,8 +151,9 @@ pub fn parse_ticket_args(args: &[String]) -> Result { query: parse_one_positional("show", &args[1..])?, }, "comment" => TicketCommand::Comment(parse_comment(&args[1..])?), - "state" => TicketCommand::State(parse_state(&args[1..])?), - "close" => TicketCommand::Close(parse_close(&args[1..])?), + "close" | "reopen" | "correct-closure" => { + TicketCommand::Case(parse_case(&args[0], &args[1..])?) + } "relation" => TicketCommand::Relation(parse_relation(&args[1..])?), "doctor" => { if args.len() != 1 { @@ -211,8 +206,7 @@ fn run_backend_command( TicketCommand::List(options) => list(backend, options), TicketCommand::Show { query } => show(backend, query), TicketCommand::Comment(options) => comment(backend, options), - TicketCommand::State(options) => state(backend, options), - TicketCommand::Close(options) => close(backend, options), + TicketCommand::Case(options) => update_case(backend, options), TicketCommand::Relation(options) => relation(backend, options), TicketCommand::Doctor => doctor(backend), } @@ -272,6 +266,19 @@ fn show(backend: &dyn TicketBackend, query: String) -> Result Result) -> String { + match key { + None => "none".into(), + Some(key) if key == "none" || key.starts_with("key:") => format!("key:{key}"), + Some(key) => key.into(), + } +} + fn is_obsolete_ticket_frontmatter_key(key: &str) -> bool { matches!( key, @@ -411,63 +428,52 @@ fn comment( Ok(success(format!("appended\t{}\t{}\n", options.query, role))) } -fn state( +fn update_case( backend: &dyn TicketBackend, - options: StateOptions, + options: CaseOptions, ) -> Result { - let id = TicketIdOrSlug::Query(options.query.clone()); - let target_state = match options.state { - StateTarget::Planning => TicketWorkflowState::Planning, - StateTarget::Ready => { - return Err(TicketCliError::new( - "ready requires Workspace repository authority; use the Browser Mark ready action or TicketMarkReady", - )); - } - StateTarget::Queued => { - return Err(TicketCliError::new( - "queued is an Orchestrator operation; use TicketQueue after MarkReady succeeds", - )); - } - StateTarget::InProgress => TicketWorkflowState::InProgress, - StateTarget::Done => { - return Err(TicketCliError::new( - "done is guarded by CompleteMergeRequest with an approved exact source ref and operation_id", - )); - } - StateTarget::Closed => { - return Err(TicketCliError::new( - "yoi ticket state closed cannot write resolution.md; use `yoi ticket close --resolution ` instead", - )); - } + let explanation = match &options.explanation { + BodySource::Message(text) => text.clone(), + BodySource::File(path) => fs::read_to_string(path).map_err(|error| { + TicketCliError::new(format!("failed to read {}: {error}", path.display())) + })?, }; - let current = backend.show(id.clone())?; - let ticket_id = current.meta.id.clone(); - let from = current.meta.workflow_state; - let change = ticket::TicketStateChange { - from: from.as_str().to_string(), - to: target_state.as_str().to_string(), - reason: "cli_state".to_string(), - author: Some("yoi ticket".to_string()), - body: format!("State changed to `{}`.\n", target_state.as_str()).into(), - references: Vec::new(), - }; - backend.set_workflow_state(id, change)?; + require_nonblank("explanation", &explanation)?; + // Resolve identity only. Never rewrite the explicit CAS with this snapshot: + // doing so would change the fingerprint of an exact replay. + let current = backend.show(TicketIdOrSlug::Query(options.query))?; + let operation_key = options.operation_key.clone(); + let updated = backend.update_case( + ¤t.meta.id, + TicketCaseUpdate { + expected_content_digest: options.expected_content_digest, + expected_state: options.expected_state, + expected_closure_operation_key: options.expected_closure_operation_key, + operation_key: options.operation_key, + action: options.action, + explanation, + references: options.references, + author: Some(options.author), + }, + )?; + // A replay returns current authority, which may already have been reopened. + // Report that state, not an inferred "closed"/"completed" success. Ok(success(format!( - "state\t{}\t{}\n", - ticket_id, - target_state.as_str() + "case\t{}\t{}\toperation-key:{}\n", + updated.meta.id, + updated.meta.workflow_state.as_str(), + operation_key ))) } -fn close( - backend: &dyn TicketBackend, - options: CloseOptions, -) -> Result { - backend.close( - TicketIdOrSlug::Query(options.query.clone()), - MarkdownText::new(read_body_source(&options.resolution)?), - )?; - Ok(success(format!("closed\t{}\n", options.query))) +fn closure_reason_label(reason: &TicketClosureReason) -> String { + match reason { + TicketClosureReason::Completed => "completed".into(), + TicketClosureReason::NotPlanned => "not_planned".into(), + TicketClosureReason::Duplicate => "duplicate".into(), + TicketClosureReason::Unknown => "unknown".into(), + TicketClosureReason::Other(text) => format!("other:{text}"), + } } fn relation( @@ -721,49 +727,154 @@ fn parse_comment(args: &[String]) -> Result { }) } -fn parse_state(args: &[String]) -> Result { - if args.len() != 2 { - return Err(TicketCliError::new( - "state requires ", - )); - } - Ok(StateOptions { - query: args[0].clone(), - state: parse_state_target(&args[1])?, - }) -} - -fn parse_close(args: &[String]) -> Result { +fn parse_case(command: &str, args: &[String]) -> Result { if args.is_empty() || args[0].starts_with('-') { - return Err(TicketCliError::new("close requires ")); + return Err(TicketCliError::new(format!("{command} requires "))); } let query = args[0].clone(); + let mut digest = None; + let mut state = None; + let mut closure_key = None; + let mut operation_key = None; + let mut reason = None; + let mut explanation = None; let mut file = None; - let mut resolution = None; + let mut references = Vec::new(); + let mut author = None; + let mut seen = std::collections::BTreeSet::new(); let mut i = 1; while i < args.len() { - match option_with_value(args, &mut i)? { - Some(("--resolution", value)) => resolution = Some(value), - Some(("--file", value)) => file = Some(PathBuf::from(value)), - Some((name, _)) => { - return Err(TicketCliError::new(format!( - "unknown close argument: {name}" - ))); + let Some((name, value)) = option_with_value(args, &mut i)? else { + return Err(TicketCliError::new(format!( + "unknown {command} argument: {}", + args[i] + ))); + }; + if name != "--reference" && !seen.insert(name) { + return Err(TicketCliError::new(format!( + "{name} may only be supplied once" + ))); + } + require_nonblank(name, &value)?; + match name { + "--expected-content-digest" => { + if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) { + return Err(TicketCliError::new( + "--expected-content-digest requires a SHA-256 hex digest", + )); + } + digest = Some(value); } - None => { + "--expected-state" => { + state = Some(TicketState::parse(&value).ok_or_else(|| { + TicketCliError::new("--expected-state requires open or closed") + })?) + } + "--expected-closure-operation-key" => { + closure_key = Some(if value == "none" { + None + } else { + let key = value.strip_prefix("key:").unwrap_or(&value); + require_nonblank("closure operation key", key)?; + Some(key.to_string()) + }); + } + "--operation-key" => operation_key = Some(value), + "--reason" if command != "reopen" => reason = Some(parse_closure_reason(&value)?), + "--explanation" => explanation = Some(value), + "--file" => file = Some(PathBuf::from(value)), + "--reference" => { + let (kind, target) = value + .split_once(':') + .ok_or_else(|| TicketCliError::new("--reference requires :"))?; + require_nonblank("reference kind", kind)?; + require_nonblank("reference target", target)?; + references.push(TicketReference { + kind: kind.into(), + target: target.into(), + }); + } + "--author" => author = Some(value), + _ => { return Err(TicketCliError::new(format!( - "unknown close argument: {}", - args[i] + "unknown {command} argument: {name}" ))); } } } - Ok(CloseOptions { + let action = match command { + "reopen" => TicketCaseAction::Reopen, + "close" => TicketCaseAction::Close { + reason: reason.ok_or_else(|| TicketCliError::new("close requires --reason"))?, + }, + "correct-closure" => TicketCaseAction::CorrectClosure { + reason: reason + .ok_or_else(|| TicketCliError::new("correct-closure requires --reason"))?, + }, + _ => unreachable!("case command is selected by the command parser"), + }; + let explanation = match (file, explanation) { + (Some(path), None) => BodySource::File(path), + (None, Some(text)) => BodySource::Message(text), + _ => { + return Err(TicketCliError::new(format!( + "{command} requires exactly one of --explanation or --file" + ))); + } + }; + Ok(CaseOptions { query, - resolution: exactly_one_body("close", file, resolution)?, + expected_content_digest: digest + .ok_or_else(|| TicketCliError::new("case action requires --expected-content-digest"))?, + expected_state: state + .ok_or_else(|| TicketCliError::new("case action requires --expected-state"))?, + expected_closure_operation_key: closure_key.ok_or_else(|| { + TicketCliError::new("case action requires --expected-closure-operation-key ") + })?, + operation_key: operation_key + .ok_or_else(|| TicketCliError::new("case action requires --operation-key"))?, + action, + explanation, + references, + // A stable default keeps retries independent of process environment. + author: author.unwrap_or_else(|| "yoi ticket".into()), }) } +fn require_nonblank(name: &str, value: &str) -> Result<(), TicketCliError> { + if value.trim().is_empty() || value.contains('\0') { + Err(TicketCliError::new(format!( + "{name} must be nonblank and contain no NUL" + ))) + } else { + Ok(()) + } +} + +fn parse_closure_reason(value: &str) -> Result { + match value { + "completed" => Ok(TicketClosureReason::Completed), + "not_planned" => Ok(TicketClosureReason::NotPlanned), + "duplicate" => Ok(TicketClosureReason::Duplicate), + "unknown" => Ok(TicketClosureReason::Unknown), + _ => { + if let Some(text) = value.strip_prefix("other:") { + require_nonblank("other reason", text)?; + if text.len() > 1024 { + return Err(TicketCliError::new( + "other reason must be at most 1024 UTF-8 bytes", + )); + } + Ok(TicketClosureReason::Other(text.into())) + } else { + Err(TicketCliError::new( + "--reason requires completed, not_planned, duplicate, unknown, or other:", + )) + } + } + } +} + fn parse_one_positional(command: &str, args: &[String]) -> Result { if args.len() != 1 || args[0].starts_with('-') { Err(TicketCliError::new(format!("{command} requires "))) @@ -783,7 +894,14 @@ fn option_with_value( "--role", "--file", "--message", - "--resolution", + "--reason", + "--explanation", + "--expected-content-digest", + "--expected-state", + "--expected-closure-operation-key", + "--operation-key", + "--reference", + "--author", "--ticket", "--kind", "--target", @@ -831,7 +949,7 @@ fn parse_list_state(raw: &str) -> Result { .any(|token| *token == "active" || *token == "all") { return Err(TicketCliError::new( - "--state active/all cannot be mixed with workflow states", + "--state active/all cannot be mixed with case states", )); } @@ -839,7 +957,7 @@ fn parse_list_state(raw: &str) -> Result { for token in tokens { let state = TicketListState::parse(token).ok_or_else(|| { TicketCliError::new(format!( - "invalid state: {token}; expected active, all, planning, ready, queued, inprogress, done, closed" + "invalid state: {token}; expected active, all, open, closed" )) })?; if !states.contains(&state) { @@ -878,18 +996,6 @@ fn truncate_inline(text: &str, max_chars: usize) -> String { out } -fn parse_state_target(value: &str) -> Result { - match value { - "planning" => Ok(StateTarget::Planning), - "ready" => Ok(StateTarget::Ready), - "queued" => Ok(StateTarget::Queued), - "inprogress" => Ok(StateTarget::InProgress), - "done" => Ok(StateTarget::Done), - "closed" => Ok(StateTarget::Closed), - _ => Err(TicketCliError::new(format!("invalid state: {value}"))), - } -} - fn parse_comment_role(value: &str) -> Result { match value { "comment" => Ok(TicketEventKind::Comment), @@ -909,12 +1015,12 @@ fn exactly_one_body( ) -> Result { match (file, message) { (Some(_), Some(_)) => Err(TicketCliError::new(format!( - "{command} accepts exactly one of --file or --message/--resolution" + "{command} accepts exactly one of --file or --message" ))), (Some(path), None) => Ok(BodySource::File(path)), (None, Some(message)) => Ok(BodySource::Message(ensure_trailing_newline(message))), (None, None) => Err(TicketCliError::new(format!( - "{command} requires --file or --message/--resolution" + "{command} requires --file or --message" ))), } } @@ -942,7 +1048,48 @@ fn default_author() -> String { } fn help_text() -> &'static str { - "yoi ticket\n\nUsage:\n yoi ticket create --title \n yoi ticket list [--state active|all|planning|ready|queued|inprogress|done|closed[,..]] [--limit <n>]\n yoi ticket show <id>\n yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>)\n yoi ticket state <id> <planning|ready|queued|inprogress|closed>\n yoi ticket close <id> (--resolution <text>|--file <path>)\n yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>]\n yoi ticket relation list [--ticket <id>] [--kind <kind>]\n yoi ticket doctor\n\nOptions:\n -h, --help Print help\n\nTargets:\n Ticket commands require the Workspace-scoped Backend selected by the shared client Target.\n Standalone never falls back to repository-local Ticket storage.\n" + "yoi ticket + +Usage: + yoi ticket create --title <title> + yoi ticket list [--state active|all|open|closed[,..]] [--limit <n>] + yoi ticket show <id> + yoi ticket comment <id> [--role comment|plan|decision|implementation_report] (--file <path>|--message <text>) + yoi ticket close <id> --reason <reason> <case-options> + yoi ticket reopen <id> <case-options> + yoi ticket correct-closure <id> --reason <reason> <case-options> + yoi ticket relation add --ticket <id> --kind <depends_on|blocks|related|supersedes|duplicate_of> --target <id> [--note <text>] + yoi ticket relation list [--ticket <id>] [--kind <kind>] + yoi ticket doctor + +Case options (required for close, reopen, correct-closure): + --expected-content-digest <sha256> Content digest from show + --expected-state <open|closed> Observed case state + --expected-closure-operation-key <key|none> Last closure key from show, retained after reopening + --operation-key <key> Stable key for this decision + --explanation <text> | --file <path> Exactly one nonblank explanation source + --reference <kind>:<target> Optional; repeat for multiple references + --author <author> Optional; defaults to yoi ticket + +Closure reasons: + completed | not_planned | duplicate | unknown | other:<text> + Closing does not imply completion; a reason must be explicitly supplied. + +Replay: + Repeat the same operation key, CAS values, reason, explanation bytes, references, + and author for an exact replay. Do not refresh CAS values on a retry. + Keep --file contents unchanged. Output reports current Backend case state, + which may differ from the original action after subsequent decisions. + The literal none denotes an absent closure key; active lists open cases. + Prefix a closure key with key: to use a literal value (e.g. key:none or key:key:foo). + +Options: + -h, --help Print help + +Targets: + Ticket commands require the Workspace-scoped Backend selected by the shared client Target. + Standalone never falls back to repository-local Ticket storage. +" } #[cfg(test)] @@ -974,4 +1121,540 @@ mod tests { assert!(output.stdout.contains("Workspace-scoped Backend")); assert!(!output.stdout.contains("repository-file Ticket backend")); } + + fn case_args(command: &str, reason: Option<&str>) -> Vec<String> { + let mut argv = args(&[ + command, + "example", + "--expected-content-digest", + &"a".repeat(64), + "--expected-state", + "open", + "--expected-closure-operation-key", + "none", + "--operation-key", + "decision-1", + "--explanation", + "Intentional decision.", + ]); + if let Some(reason) = reason { + argv.extend(args(&["--reason", reason])); + } + argv + } + + fn command(argv: &[String]) -> TicketCommand { + match parse_ticket_args(argv).unwrap() { + TicketCli::Command(command) => command, + TicketCli::Help => panic!("expected command"), + } + } + + fn replace_value(argv: &mut [String], flag: &str, value: &str) { + let index = argv.iter().position(|arg| arg == flag).unwrap(); + argv[index + 1] = value.into(); + } + + #[test] + fn case_commands_require_explicit_reason_and_explanation_with_replay_preconditions() { + for name in ["close", "reopen", "correct-closure"] { + let reason = (name != "reopen").then_some("unknown"); + let argv = case_args(name, reason); + assert!(parse_ticket_args(&argv).is_ok(), "{name}"); + for flag in [ + "--expected-content-digest", + "--expected-state", + "--expected-closure-operation-key", + "--operation-key", + "--explanation", + ] { + let mut missing = argv.clone(); + let index = missing.iter().position(|arg| arg == flag).unwrap(); + missing.drain(index..index + 2); + assert!( + parse_ticket_args(&missing).is_err(), + "{name} missing {flag}" + ); + let mut blank = argv.clone(); + replace_value(&mut blank, flag, " \n "); + assert!(parse_ticket_args(&blank).is_err(), "{name} blank {flag}"); + } + } + for name in ["close", "correct-closure"] { + assert!( + parse_ticket_args(&case_args(name, None)) + .unwrap_err() + .to_string() + .contains("requires --reason") + ); + } + assert!(parse_ticket_args(&case_args("reopen", Some("completed"))).is_err()); + for name in ["state", "complete"] { + assert!(parse_ticket_args(&args(&[name, "example", "closed"])).is_err()); + } + } + + #[test] + fn close_and_correction_parse_each_reason_without_a_success_default() { + for name in ["close", "correct-closure"] { + for (raw, expected) in [ + ("completed", TicketClosureReason::Completed), + ("not_planned", TicketClosureReason::NotPlanned), + ("duplicate", TicketClosureReason::Duplicate), + ("unknown", TicketClosureReason::Unknown), + ( + "other:Deferred pending budget", + TicketClosureReason::Other("Deferred pending budget".into()), + ), + ] { + let TicketCommand::Case(options) = command(&case_args(name, Some(raw))) else { + panic!() + }; + let actual = match options.action { + TicketCaseAction::Close { reason } + | TicketCaseAction::CorrectClosure { reason } => reason, + TicketCaseAction::Reopen => panic!(), + }; + assert_eq!(actual, expected); + } + for raw in [ + "success", + "other:", + "other: ", + "other:\0", + &format!("other:{}", "é".repeat(513)), + ] { + assert!( + parse_ticket_args(&case_args(name, Some(raw))).is_err(), + "{raw:?}" + ); + } + } + } + + #[test] + fn case_parser_rejects_ambiguous_or_malformed_requests() { + let base = case_args("close", Some("not_planned")); + for (flag, value) in [ + ("--expected-content-digest", "not-a-digest"), + ("--expected-state", "running"), + ("--operation-key", "key\0"), + ("--reference", "missing-separator"), + ("--reference", ":target"), + ("--reference", "result:"), + ("--author", " "), + ] { + let mut argv = base.clone(); + if argv.iter().any(|arg| arg == flag) { + replace_value(&mut argv, flag, value); + } else { + argv.extend(args(&[flag, value])); + } + assert!(parse_ticket_args(&argv).is_err(), "{flag} {value:?}"); + } + for extra in [ + args(&["--file", "explanation.md"]), + args(&["--operation-key", "second"]), + args(&["--resolution", "old untyped close"]), + ] { + let mut argv = base.clone(); + argv.extend(extra); + assert!(parse_ticket_args(&argv).is_err()); + } + } + + #[test] + fn list_accepts_only_case_states_and_active_means_open() { + for (raw, expected) in [ + ("active", ListState::Active), + ("all", ListState::All), + ("open", ListState::States(vec![TicketListState::Open])), + ("closed", ListState::States(vec![TicketListState::Closed])), + ( + "open,closed,open", + ListState::States(vec![TicketListState::Open, TicketListState::Closed]), + ), + ] { + let TicketCommand::List(options) = command(&args(&["list", "--state", raw])) else { + panic!() + }; + assert_eq!(options.state, expected); + } + for raw in ["running", "active,closed", "all,open"] { + assert!(parse_ticket_args(&args(&["list", "--state", raw])).is_err()); + } + let fake = FakeBackend::new(); + run_backend_command(command(&args(&["list"])), &fake).unwrap(); + assert_eq!( + fake.list_filter.borrow().as_ref(), + Some(&TicketListQuery::active()) + ); + } + + // Narrow in-memory adapter: no Workspace, product process, network, or + // filesystem Ticket storage. Unused authority methods panic so no fallback + // event/state/storage operation can make these runtime tests pass. + struct FakeBackend { + current: std::cell::RefCell<ticket::Ticket>, + receipts: std::cell::RefCell<std::collections::BTreeMap<String, TicketCaseUpdate>>, + requests: std::cell::RefCell<Vec<(String, TicketCaseUpdate)>>, + list_filter: std::cell::RefCell<Option<TicketListQuery>>, + } + + fn fixture() -> ticket::Ticket { + ticket::Ticket { + meta: ticket::TicketMeta { + id: "T-example".into(), + resource_key: None, + slug: "example".into(), + title: "Case judgment".into(), + status: ticket::ExtensibleTicketStatus::Open, + kind: "task".into(), + priority: "normal".into(), + labels: vec![], + created_at: None, + updated_at: None, + assignee: None, + readiness: None, + risk_flags: vec![], + workflow_state: TicketState::Open, + workflow_state_explicit: true, + queued_by: None, + queued_at: None, + targets: vec![], + raw: Default::default(), + }, + document: ticket::TicketDocument { + body: "Requirements".into(), + raw_frontmatter: Default::default(), + }, + events: vec![], + artifacts: vec![], + relations: Default::default(), + resolution: None, + closure_operation_key: None, + current_closure: None, + case_history: vec![], + legacy_workflow_snapshot: None, + } + } + + impl FakeBackend { + fn new() -> Self { + Self { + current: std::cell::RefCell::new(fixture()), + receipts: Default::default(), + requests: Default::default(), + list_filter: Default::default(), + } + } + + fn argv(&self, action: &str, reason: Option<&str>, key: &str) -> Vec<String> { + let current = self.current.borrow(); + let mut argv = case_args(action, reason); + replace_value( + &mut argv, + "--expected-content-digest", + &ticket::ticket_content_digest(¤t), + ); + replace_value( + &mut argv, + "--expected-state", + current.meta.workflow_state.as_str(), + ); + replace_value( + &mut argv, + "--expected-closure-operation-key", + &closure_key_token(current.closure_operation_key.as_deref()), + ); + replace_value(&mut argv, "--operation-key", key); + argv + } + } + + macro_rules! unused_backend_methods { + ($(fn $name:ident(&self $(, $arg:ident: $ty:ty)*) -> $ret:ty;)*) => { + $(fn $name(&self $(, _: $ty)*) -> $ret { panic!("unexpected authority method: {}", stringify!($name)) })* + }; + } + + impl TicketBackend for FakeBackend { + fn list(&self, filter: TicketListQuery) -> ticket::Result<Vec<TicketSummary>> { + self.list_filter.replace(Some(filter)); + Ok(vec![]) + } + fn show(&self, _: TicketIdOrSlug) -> ticket::Result<ticket::Ticket> { + Ok(self.current.borrow().clone()) + } + fn update_case( + &self, + id: &str, + request: TicketCaseUpdate, + ) -> ticket::Result<ticket::Ticket> { + self.requests + .borrow_mut() + .push((id.into(), request.clone())); + let mut current = self.current.borrow_mut(); + if let Some(receipt) = self.receipts.borrow().get(&request.operation_key) { + if receipt != &request { + return Err(ticket::TicketError::OperationFingerprintMismatch { + operation_key: request.operation_key, + }); + } + return Ok(current.clone()); + } + let digest = ticket::ticket_content_digest(¤t); + if request.expected_content_digest != digest { + return Err(ticket::TicketError::StaleContent { + expected: request.expected_content_digest, + actual: digest, + }); + } + if request.expected_state != current.meta.workflow_state { + return Err(ticket::TicketError::StaleWorkflowState { + expected: request.expected_state.to_string(), + actual: current.meta.workflow_state.to_string(), + }); + } + if request.expected_closure_operation_key != current.closure_operation_key { + return Err(ticket::TicketError::StaleClosureOperationKey { + expected: request.expected_closure_operation_key, + actual: current.closure_operation_key.clone(), + }); + } + match &request.action { + TicketCaseAction::Close { reason } + | TicketCaseAction::CorrectClosure { reason } => { + current.meta.workflow_state = TicketState::Closed; + current.closure_operation_key = Some(request.operation_key.clone()); + current.current_closure = Some(ticket::TicketClosureJudgment { + operation_key: request.operation_key.clone(), + reason: reason.clone(), + explanation: request.explanation.clone(), + references: request.references.clone(), + author: request.author.clone().unwrap(), + at: "fixture-time".into(), + }); + } + TicketCaseAction::Reopen => { + current.meta.workflow_state = TicketState::Open; + current.current_closure = None; + } + } + self.receipts + .borrow_mut() + .insert(request.operation_key.clone(), request); + Ok(current.clone()) + } + unused_backend_methods! { + fn default_intake_ready_state_change_body(&self, from: &str) -> String; + fn create(&self, input: NewTicket) -> ticket::Result<ticket::TicketRef>; + fn edit_item(&self, id: TicketIdOrSlug, edit: ticket::TicketItemEdit) -> ticket::Result<ticket::Ticket>; + fn dependency_check(&self, id: TicketIdOrSlug) -> ticket::Result<ticket::TicketDependencyCheck>; + fn add_event(&self, id: TicketIdOrSlug, event: NewTicketEvent) -> ticket::Result<()>; + fn add_state_changed(&self, id: TicketIdOrSlug, change: ticket::TicketStateChange) -> ticket::Result<()>; + fn add_intake_summary(&self, id: TicketIdOrSlug, summary: ticket::TicketIntakeSummary) -> ticket::Result<()>; + fn mark_ready(&self, id: TicketIdOrSlug, request: ticket::TicketMarkReady) -> ticket::Result<ticket::Ticket>; + fn queue_ready(&self, id: TicketIdOrSlug, queued_by: &str) -> ticket::Result<ticket::TicketQueueOutcome>; + fn add_ticket_relation(&self, id: TicketIdOrSlug, relation: NewTicketRelation) -> ticket::Result<ticket::TicketRelation>; + fn remove_ticket_relation(&self, id: TicketIdOrSlug, kind: TicketRelationKind, target: TicketIdOrSlug) -> ticket::Result<ticket::TicketRelation>; + fn query_ticket_relations(&self, id: Option<TicketIdOrSlug>, kind: Option<TicketRelationKind>) -> ticket::Result<Vec<ticket::TicketRelation>>; + fn relation_view(&self, id: TicketIdOrSlug) -> ticket::Result<ticket::TicketRelationView>; + fn add_orchestration_plan_record(&self, id: TicketIdOrSlug, record: ticket::NewOrchestrationPlanRecord) -> ticket::Result<ticket::OrchestrationPlanRecord>; + fn query_orchestration_plan_records(&self, id: Option<TicketIdOrSlug>, kind: Option<ticket::OrchestrationPlanKind>) -> ticket::Result<Vec<ticket::OrchestrationPlanRecord>>; + fn doctor(&self) -> ticket::Result<ticket::TicketDoctorReport>; + } + } + + #[test] + fn reasoned_actions_use_update_case_and_replay_reports_current_authority() { + let fake = FakeBackend::new(); + let mut close = fake.argv("close", Some("not_planned"), "close-1"); + close.extend(args(&[ + "--author", + "human:alice", + "--reference", + "document:https://example.invalid/result", + "--reference", + "ticket:T-other", + ])); + let output = run_backend_command(command(&close), &fake).unwrap(); + assert_eq!( + output.stdout, + "case\tT-example\tclosed\toperation-key:close-1\n" + ); + let (id, request) = fake.requests.borrow()[0].clone(); + assert_eq!(id, "T-example"); + assert_eq!(request.explanation, "Intentional decision."); + assert_eq!(request.author.as_deref(), Some("human:alice")); + assert_eq!(request.references.len(), 2); + assert_eq!( + serde_json::to_value(&request).unwrap()["action"], + serde_json::json!({"action": "close", "reason": "not_planned"}) + ); + assert_eq!( + request.references[0].target, + "https://example.invalid/result" + ); + assert_eq!( + request.action, + TicketCaseAction::Close { + reason: TicketClosureReason::NotPlanned + } + ); + let correct = fake.argv("correct-closure", Some("duplicate"), "correct-1"); + run_backend_command(command(&correct), &fake).unwrap(); + assert_eq!( + fake.current + .borrow() + .current_closure + .as_ref() + .unwrap() + .reason, + TicketClosureReason::Duplicate + ); + let reopen = fake.argv("reopen", None, "reopen-1"); + run_backend_command(command(&reopen), &fake).unwrap(); + let output = run_backend_command(command(&close), &fake).unwrap(); + assert_eq!( + output.stdout, + "case\tT-example\topen\toperation-key:close-1\n" + ); + assert_eq!(fake.requests.borrow().last().unwrap().1, request); + assert_eq!(fake.receipts.borrow().len(), 3); + assert_eq!( + fake.current.borrow().closure_operation_key.as_deref(), + Some("correct-1") + ); + let mut changed = close; + replace_value(&mut changed, "--explanation", "A different judgment."); + let error = run_backend_command(command(&changed), &fake).unwrap_err(); + assert!(error.to_string().contains("different request fingerprint")); + assert_eq!(fake.current.borrow().meta.workflow_state, TicketState::Open); + } + + #[test] + fn stale_case_preconditions_propagate_backend_rejection_without_retry_or_success() { + for (flag, value, diagnostic) in [ + ( + "--expected-content-digest", + "b".repeat(64), + "stale ticket content", + ), + ( + "--expected-state", + "closed".into(), + "stale ticket workflow state", + ), + ( + "--expected-closure-operation-key", + "unobserved".into(), + "stale Ticket closure operation key", + ), + ] { + let fake = FakeBackend::new(); + let before = fake.current.borrow().clone(); + let mut argv = fake.argv("close", Some("completed"), "decision-1"); + replace_value(&mut argv, flag, &value); + let error = run_backend_command(command(&argv), &fake).unwrap_err(); + assert!(error.to_string().contains(diagnostic), "{error}"); + assert_eq!(*fake.current.borrow(), before); + assert_eq!(fake.requests.borrow().len(), 1); + assert!(fake.receipts.borrow().is_empty()); + } + } + + #[test] + fn explanation_file_preserves_bytes_and_blank_files_never_submit_a_case_action() { + let temp = tempfile::TempDir::new().unwrap(); + let path = temp.path().join("explanation.md"); + let fake = FakeBackend::new(); + let mut argv = fake.argv("close", Some("unknown"), "close-file"); + let index = argv.iter().position(|arg| arg == "--explanation").unwrap(); + argv[index] = "--file".into(); + argv[index + 1] = path.display().to_string(); + fs::write(&path, " \n ").unwrap(); + assert!(run_backend_command(command(&argv), &fake).is_err()); + assert!(fake.requests.borrow().is_empty()); + fs::write(&path, "Decision without trailing newline").unwrap(); + run_backend_command(command(&argv), &fake).unwrap(); + run_backend_command(command(&argv), &fake).unwrap(); + assert_eq!( + fake.requests.borrow()[0].1.explanation, + "Decision without trailing newline" + ); + assert_eq!(fake.requests.borrow()[0].1, fake.requests.borrow()[1].1); + } + + #[test] + fn show_exposes_case_cas_and_explicit_closure_reason() { + let fake = FakeBackend::new(); + let output = run_backend_command(command(&args(&["show", "example"])), &fake).unwrap(); + assert!(output.stdout.contains(&format!( + "Content digest: {}", + ticket::ticket_content_digest(&fake.current.borrow()) + ))); + assert!(output.stdout.contains("Closure operation key: none")); + let argv = fake.argv("close", Some("not_planned"), "close-show"); + run_backend_command(command(&argv), &fake).unwrap(); + let output = run_backend_command(command(&args(&["show", "example"])), &fake).unwrap(); + assert!(output.stdout.contains("State: closed")); + assert!(output.stdout.contains("Closure operation key: close-show")); + assert!(output.stdout.contains("Closure reason: not_planned")); + assert!( + output + .stdout + .contains("Closure explanation: Intentional decision.") + ); + assert!(!output.stdout.contains("Closure reason: completed")); + } + + #[test] + fn help_documents_reasoned_case_actions_and_exact_replay_not_bare_state_mutation() { + let help = help_text(); + for snippet in [ + "yoi ticket close", + "yoi ticket reopen", + "yoi ticket correct-closure", + "--reason", + "--explanation", + "--operation-key", + "Do not refresh CAS values on a retry.", + "active lists open cases", + ] { + assert!(help.contains(snippet), "{snippet}"); + } + assert!(!help.contains("yoi ticket state")); + assert!(!help.contains("--resolution")); + } + + #[test] + fn literal_closure_keys_and_tagged_actions_preserve_the_update_case_wire_contract() { + for (token, key) in [ + ("none", None), + ("key:none", Some("none")), + ("key:key:example", Some("key:example")), + ("close-1", Some("close-1")), + ] { + let mut argv = case_args("reopen", None); + replace_value(&mut argv, "--expected-closure-operation-key", token); + let TicketCommand::Case(options) = command(&argv) else { + panic!() + }; + assert_eq!(options.expected_closure_operation_key.as_deref(), key); + assert_eq!( + serde_json::to_value(&options.action).unwrap(), + serde_json::json!({"action": "reopen"}) + ); + } + let TicketCommand::Case(options) = + command(&case_args("correct-closure", Some("other:Budget changed"))) + else { + panic!() + }; + assert_eq!( + serde_json::to_value(&options.action).unwrap(), + serde_json::json!({"action": "correct_closure", "reason": {"other": "Budget changed"}}) + ); + } } diff --git a/docs/development/ticket-case-state.md b/docs/development/ticket-case-state.md new file mode 100644 index 000000000..85ee138bb --- /dev/null +++ b/docs/development/ticket-case-state.md @@ -0,0 +1,53 @@ +# Ticket案件状態・終了判断 + +Ticketの現行案件状態は `open` / `closed` のみ。`open` は引き続き扱う案件であり、着手可能・queue済みではない。`closed` は現時点で案件としての対応を終えた判断であり、要求実現済みとは限らない。Orchestratorの追跡、実作業拘束、Workerの稼働、MR証跡とは別の正本である。 + +## 判断の公開境界 + +通常Tool `TicketCaseUpdate` とWIPの `tickets/T-*/case` は、API `POST /workspaces/{workspace_id}/tickets/{ticket_id}/case` と同じ判断契約を使用する。旧complete/state/close専用判断APIは現行契約ではない。 + +- `Close { reason }`: openをclosedにする。 +- `Reopen`: closedをopenにし、現在の終了判断を外す。 +- `CorrectClosure { reason }`: closedの現在判断を訂正する。過去の判断は変更しない。 +- 理由は `completed` / `not_planned` / `duplicate` / `unknown` / `{ other: string }`。`completed` だけが要求実現済みを表す。すべての操作に説明を必要とし、任意のtyped成果参照を保持する。 + +`ShowTicket`/detailは現在の終了判断、最後の終了判断のoperation key、close/reopen/訂正のimmutable履歴とlegacy handoffを返す。query/listにも現在の判断と観測用key、handoffを公開する。再開後も過去の理由、説明、成果参照、判断主体、時刻を失わない。判断主体は認証済みBackend actorから設定し、公開API/LLM入力からの偽装を許可しない。案件のcloseにRepository/MR/第三者承認や固定段階の通過を一律要求しない。 + +## 更新競合と再送 + +更新前に次の三つを同じ判断transaction内で照合する。 + +1. `expected_content_digest`: 実際のeditable要件(identity/title/body/順序付きtargets)のSHA-256。threadや案件判断履歴はこのdigestを動かさない。 +2. `expected_state`: 観測した案件状態。 +3. `expected_closure_operation_key`: 観測した最後の終了判断のkey。未closeなら明示的なnullを送る。省略はunknownとして拒否する。 + +最後の終了判断のkeyはreopen後も残る。これにより、本文とopen状態が同じでもopen→closed→openを見落とさず、closedの訂正同士の競合も検知する。時刻や到着順だけではこの一致を証明できない。 + +operation keyのexact replayは、全入力と認証済みsource identityのfingerprintに一致する場合だけ受理する。古い操作を再適用せず、現在の案件を返す。異入力の同key再利用は拒否する。判断履歴とreceipt、案件状態、Workspaceの作業releaseは同じtransactionで扱い、hook失敗時も途中の判断を残さない。 + +## 担当・作業・資産との分離 + +closeは案件の実作業拘束を終える一方、T-715の終端担当責任を表示用に保持する。reopenから古い実行identity、claim、grantを復活させない。新しい実作業には明示的なrouting/claimが必要であり、open案件を自動queueと解釈しない。close/reopen/訂正そのものはWorker起動・停止・復元・削除、担当変更、資産grant、MR承認・統合を行わない。停止・削除のhold/pin/attachment等の独立guardは維持する。 + +## 旧保存データの移行 + +独立T-739 sourceではTicket component schema7→9、Workspace schema89→90を使用する。component6からの既存移行も維持する。 + +| 旧workflow state | 現行案件状態 | 終了理由 | +| --- | --- | --- | +| planning / ready / queued / inprogress | open | なし | +| done / closed | closed | unknown | + +旧doneを成功の証拠に昇格しない。移行による終了判断はmigration actorを明示し、旧resolutionの説明を保持する。旧thread、completion/close receipt、担当履歴、MR証跡を改ざんしない。旧phase、explicit flag、status、queued_by/queued_at、resolutionをimmutable `typed_ticket_legacy_workflow_snapshot` に保存し、`legacy_handoff` として後続追跡/routing移行へ渡す。これは現行案件状態の第二正本ではない。移行だけで過去のWorkerや終了済み作業を起動しない。 + +component schema8は並行T-740のタグ契約が所有するため、この独立sourceは8を推測して9へ変換せず安全に拒否する。T-740との統合時には、タグschemaと案件schemaの明示的な合成、7およびタグあり8からの移行、再実行、タグ/監査/終了履歴保持を検証する必要がある。番号を分けただけでは合成完了と扱わない。 + +## 検証境界 + +- `crates/ticket/tests/case.rs`: Repository/MRなしの理由、再開/訂正、actor/ref/history、CAS/ABA、exact replay/異入力拒否、並行close、Workspace境界、transaction rollback。 +- `crates/ticket/tests/migration.rs`: 六状態、旧thread/receipt/担当/MR参照の保持、理由不明、再open、schema8の安全な拒否。 +- Workspace Store: 六状態×MRあり/なし、終端担当、旧queue/handoff、migration resumption、Workerを起動しない移行。 +- Workspace API/authority/lifecycle: 偽装actor・無権限・他Workspace・旧route拒否、作業releaseと表示責任、reopen時の新routing identity、独立削除guardとWorkspace purge rollback。 +- Worker通常Tool/WIP、Client、CLI、Web parser: 同じcase入力、モデルに見えるnullable観測keyと履歴、Backend以外のfallback禁止、stale失敗の伝達。 + +実経路統合・Orchestrator追跡の置換は後続単位の責務であり、以上の単独source検証をT-740合成や実環境更新の検証と混同しない。 diff --git a/docs/report/2026-10-10-subworker-cwd-attachment-boundary.md b/docs/report/2026-10-10-subworker-cwd-attachment-boundary.md new file mode 100644 index 000000000..618cbc7d8 --- /dev/null +++ b/docs/report/2026-10-10-subworker-cwd-attachment-boundary.md @@ -0,0 +1,10 @@ +# SubWorker cwd と複数 subtree 分担の障壁 + +T-739 実装時の観測。SubWorkerSpawn の説明では `cwd` は default tool cwd であり、Workdir root や capability を変えない。しかし次の child では、ファイルツールの attachment が cwd subtree を root として扱い、明示した sibling write scope を利用できないと報告された。 + +- `ticket-consumers`: cwd `crates/worker`。worker、client、yoi、manifest の対象ファイルに限定した write scope を委譲したが、通常 Read/Edit の logical path は worker 配下に限定された。committed session の報告 `E01a12680-0113-7552-a54b-f7fabc04506c`。 +- `ticket-api`: cwd `crates/server-api`。server-api、web、resources/prompts を明示的に委譲したが、Web/Prompt/生成出力は attachment 外として未変更。報告 `E01a1267f-7506-7a43-b61e-2cacbe092834`。 + +親は拒否された child に別経路で書かせず、境界内の部分結果だけを確認した。解放後、Client/CLI/Web は個別 cwd と明示 scope の別 child に分け、Prompt/生成同期は親が担当した。予想より細かい分担と再統合が必要になった。 + +改善案: Spawn の documented cwd 契約と file tool logical-root の解決を照合する。cwd が attachment を狭める仕様なら spawn 前に sibling scope を明示的に拒否し、実効 logical root と利用可能 subtree を返す。default cwd と logical root が別なら、その差を Read/Edit のエラーおよび初期 context に表示する。scope 拒否を回避する仕組みの要望ではなく、委譲時に有効な分担を予測できる診断の要望である。 diff --git a/openapi/server-api.json b/openapi/server-api.json index 53424c5a8..3d32fa641 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -298,30 +298,6 @@ ], "type": "object" }, - "BrowserCloseTicketRequest": { - "additionalProperties": false, - "properties": { - "expected_content_digest": { - "type": "string" - }, - "expected_state": { - "$ref": "#/components/schemas/BrowserTicketWorkflowState" - }, - "operation_key": { - "type": "string" - }, - "resolution": { - "type": "string" - } - }, - "required": [ - "operation_key", - "expected_content_digest", - "expected_state", - "resolution" - ], - "type": "object" - }, "BrowserCreateWorkerResponse": { "additionalProperties": false, "properties": { @@ -411,6 +387,13 @@ "additionalProperties": false, "type": "object" }, + "BrowserTicketState": { + "enum": [ + "open", + "closed" + ], + "type": "string" + }, "BrowserTicketTargetEdit": { "oneOf": [ { @@ -457,51 +440,6 @@ ], "type": "string" }, - "BrowserTicketWorkflowState": { - "enum": [ - "planning", - "ready", - "queued", - "inprogress", - "done", - "closed" - ], - "type": "string" - }, - "BrowserTransitionTicketStateRequest": { - "additionalProperties": false, - "properties": { - "body": { - "type": [ - "string", - "null" - ] - }, - "expected_content_digest": { - "type": "string" - }, - "expected_state": { - "$ref": "#/components/schemas/BrowserTicketWorkflowState" - }, - "operation_key": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "state": { - "$ref": "#/components/schemas/BrowserTicketWorkflowState" - } - }, - "required": [ - "state", - "operation_key", - "expected_content_digest", - "expected_state", - "reason" - ], - "type": "object" - }, "BrowserWorkerWorkingDirectorySelection": { "additionalProperties": false, "properties": { @@ -1091,37 +1029,6 @@ ], "type": "object" }, - "CompleteTicketRequest": { - "additionalProperties": false, - "properties": { - "expected_content_digest": { - "type": "string" - }, - "expected_state": { - "$ref": "#/components/schemas/TicketWorkflowState" - }, - "operation_key": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "references": { - "default": [], - "items": { - "$ref": "#/components/schemas/TicketReference" - }, - "type": "array" - } - }, - "required": [ - "operation_key", - "expected_content_digest", - "expected_state", - "reason" - ], - "type": "object" - }, "CompletionContext": { "properties": { "argument": { @@ -5638,7 +5545,7 @@ "workflow_state": { "anyOf": [ { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState" }, { "type": "null" @@ -10598,6 +10505,29 @@ }, "type": "array" }, + "case_history": { + "items": { + "$ref": "#/components/schemas/TicketCaseHistoryEntry" + }, + "type": "array" + }, + "closure_operation_key": { + "description": "Last closure/correction operation key; retained when reopened (ABA guard).", + "type": [ + "string", + "null" + ] + }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "document": { "$ref": "#/components/schemas/TicketDocument" }, @@ -10607,6 +10537,16 @@ }, "type": "array" }, + "legacy_workflow_snapshot": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketLegacyWorkflowSnapshot" + }, + { + "type": "null" + } + ] + }, "meta": { "$ref": "#/components/schemas/TicketMeta" }, @@ -10629,7 +10569,8 @@ "document", "events", "artifacts", - "relations" + "relations", + "case_history" ], "type": "object" }, @@ -10777,6 +10718,19 @@ ], "type": "object" }, + "TicketAttention": { + "enum": [ + "unresolved_review", + "missing_commit", + "blocked", + "unblocked", + "awaiting_review", + "unresolved_changes", + "stale_after_rescope", + "missing_evidence" + ], + "type": "string" + }, "TicketBodyReplacement": { "properties": { "new_string": { @@ -10796,12 +10750,105 @@ ], "type": "object" }, - "TicketCompletion": { - "additionalProperties": false, - "description": "Audited completion independent of repositories, Git, or Merge Requests.", + "TicketCaseAction": { + "oneOf": [ + { + "additionalProperties": false, + "properties": { + "action": { + "const": "close", + "type": "string" + }, + "reason": { + "$ref": "#/components/schemas/TicketClosureReason" + } + }, + "required": [ + "action", + "reason" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "action": { + "const": "reopen", + "type": "string" + } + }, + "required": [ + "action" + ], + "type": "object" + }, + { + "additionalProperties": false, + "properties": { + "action": { + "const": "correct_closure", + "type": "string" + }, + "reason": { + "$ref": "#/components/schemas/TicketClosureReason" + } + }, + "required": [ + "action", + "reason" + ], + "type": "object" + } + ] + }, + "TicketCaseHistoryEntry": { + "description": "Immutable case decision, including reopening and corrections. A correction\nadds a new judgment; it never overwrites the old reason, actor or references.", "properties": { + "action": { + "$ref": "#/components/schemas/TicketCaseAction" + }, + "at": { + "type": "string" + }, "author": { - "default": null, + "type": "string" + }, + "explanation": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "previous_closure_operation_key": { + "type": [ + "string", + "null" + ] + }, + "references": { + "items": { + "$ref": "#/components/schemas/TicketReference" + }, + "type": "array" + } + }, + "required": [ + "operation_key", + "action", + "explanation", + "references", + "author", + "at" + ], + "type": "object" + }, + "TicketCaseUpdateRequest": { + "additionalProperties": false, + "properties": { + "action": { + "$ref": "#/components/schemas/TicketCaseAction" + }, + "expected_closure_operation_key": { "type": [ "string", "null" @@ -10811,12 +10858,12 @@ "type": "string" }, "expected_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState" }, - "operation_key": { + "explanation": { "type": "string" }, - "reason": { + "operation_key": { "type": "string" }, "references": { @@ -10830,11 +10877,73 @@ "required": [ "expected_content_digest", "expected_state", + "expected_closure_operation_key", "operation_key", - "reason" + "action", + "explanation" ], "type": "object" }, + "TicketClosureJudgment": { + "properties": { + "at": { + "type": "string" + }, + "author": { + "type": "string" + }, + "explanation": { + "type": "string" + }, + "operation_key": { + "type": "string" + }, + "reason": { + "$ref": "#/components/schemas/TicketClosureReason" + }, + "references": { + "items": { + "$ref": "#/components/schemas/TicketReference" + }, + "type": "array" + } + }, + "required": [ + "operation_key", + "reason", + "explanation", + "references", + "author", + "at" + ], + "type": "object" + }, + "TicketClosureReason": { + "description": "Reason attached to a closure judgment, not a classification tag or success inference.\n`Other` must be nonblank, at most 1024 UTF-8 bytes, and contain no NUL.", + "oneOf": [ + { + "enum": [ + "completed", + "not_planned", + "duplicate", + "unknown" + ], + "type": "string" + }, + { + "additionalProperties": false, + "properties": { + "other": { + "type": "string" + } + }, + "required": [ + "other" + ], + "type": "object" + } + ] + }, "TicketDependencyCheck": { "properties": { "blockers": { @@ -10904,6 +11013,19 @@ "body_truncated": { "type": "boolean" }, + "closure_history": { + "items": { + "$ref": "#/components/schemas/TicketCaseHistoryEntry" + }, + "type": "array" + }, + "closure_operation_key": { + "description": "Last closure/correction key, retained across reopening for ABA-safe CAS.", + "type": [ + "string", + "null" + ] + }, "content_digest": { "type": "string" }, @@ -10913,6 +11035,16 @@ "null" ] }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "current_worker": { "anyOf": [ { @@ -10950,6 +11082,17 @@ }, "type": "array" }, + "legacy_handoff": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketLegacyWorkflowSnapshot" + }, + { + "type": "null" + } + ], + "description": "Original migration handoff; not another current state authority." + }, "linked_objectives": { "items": { "$ref": "#/components/schemas/ObjectiveLinkSummary" @@ -11015,7 +11158,7 @@ "type": "array" }, "state": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" }, "targets": { "items": { @@ -11057,7 +11200,8 @@ "action_eligibility", "merge_requests", "evidence", - "record_source" + "record_source", + "closure_history" ], "type": "object" }, @@ -11696,6 +11840,44 @@ }, "type": "object" }, + "TicketLegacyWorkflowSnapshot": { + "description": "Original migration snapshot for downstream routing/tracking handoff only.\nThis is not a second current state authority.", + "properties": { + "queued_at": { + "type": [ + "string", + "null" + ] + }, + "queued_by": { + "type": [ + "string", + "null" + ] + }, + "resolution": { + "type": [ + "string", + "null" + ] + }, + "status": { + "type": "string" + }, + "workflow_state": { + "type": "string" + }, + "workflow_state_explicit": { + "type": "boolean" + } + }, + "required": [ + "workflow_state", + "workflow_state_explicit", + "status" + ], + "type": "object" + }, "TicketListHttpQuery": { "additionalProperties": false, "properties": { @@ -11726,9 +11908,37 @@ "TicketListItemSummary": { "additionalProperties": false, "properties": { + "closure_operation_key": { + "description": "Last closure/correction key, retained across reopening for ABA-safe CAS.", + "type": [ + "string", + "null" + ] + }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "id": { "type": "string" }, + "legacy_handoff": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketLegacyWorkflowSnapshot" + }, + { + "type": "null" + } + ], + "description": "Original migration handoff; not another current state authority." + }, "priority": { "type": "string" }, @@ -11751,7 +11961,7 @@ "type": "string" }, "state": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" }, "title": { "type": "string" @@ -12014,7 +12224,8 @@ ] }, "workflow_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState", + "description": "Case state only; field name retained temporarily, never execution progress." }, "workflow_state_explicit": { "type": "boolean" @@ -12070,6 +12281,13 @@ "minimum": 0, "type": "integer" }, + "closure_operation_key": { + "description": "Last closure/correction key, retained across reopening for ABA-safe CAS.", + "type": [ + "string", + "null" + ] + }, "content_digest": { "type": "string" }, @@ -12079,6 +12297,16 @@ "null" ] }, + "current_closure": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketClosureJudgment" + }, + { + "type": "null" + } + ] + }, "current_worker": { "anyOf": [ { @@ -12095,6 +12323,17 @@ "id": { "type": "string" }, + "legacy_handoff": { + "anyOf": [ + { + "$ref": "#/components/schemas/TicketLegacyWorkflowSnapshot" + }, + { + "type": "null" + } + ], + "description": "Original migration handoff; not another current state authority." + }, "linked_objective_ids": { "items": { "type": "string" @@ -12164,7 +12403,7 @@ ] }, "state": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" }, "title": { "type": "string" @@ -12217,7 +12456,7 @@ "attention": { "default": [], "items": { - "type": "string" + "$ref": "#/components/schemas/TicketAttention" }, "type": "array" }, @@ -12289,7 +12528,7 @@ "states": { "default": [], "items": { - "type": "string" + "$ref": "#/components/schemas/BrowserTicketState" }, "type": "array" }, @@ -12449,7 +12688,7 @@ "TicketRelationBlocker": { "properties": { "blocking_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState" }, "blocking_ticket": { "type": "string" @@ -12762,48 +13001,12 @@ } ] }, - "TicketStateUpdate": { - "additionalProperties": false, - "description": "External state mutation with content/state CAS and an immutable operation receipt.\nExact replay returns the current authoritative Ticket without a new event or\nreapplying the recorded state, even after later edits or reopening.\nSetting `state` to Closed also stores `reason` as the resolution; reopening\nchanges Ticket metadata only and never revives released work identities.", - "properties": { - "author": { - "default": null, - "type": [ - "string", - "null" - ] - }, - "expected_content_digest": { - "type": "string" - }, - "expected_state": { - "$ref": "#/components/schemas/TicketWorkflowState" - }, - "operation_key": { - "type": "string" - }, - "reason": { - "type": "string" - }, - "references": { - "default": [], - "items": { - "$ref": "#/components/schemas/TicketReference" - }, - "type": "array" - }, - "state": { - "$ref": "#/components/schemas/TicketWorkflowState" - } - }, - "required": [ - "expected_content_digest", - "expected_state", - "state", - "operation_key", - "reason" + "TicketState": { + "enum": [ + "open", + "closed" ], - "type": "object" + "type": "string" }, "TicketStatus": { "enum": [ @@ -12869,7 +13072,8 @@ ] }, "workflow_state": { - "$ref": "#/components/schemas/TicketWorkflowState" + "$ref": "#/components/schemas/TicketState", + "description": "Case state only; field name retained temporarily, never execution progress." }, "workflow_state_explicit": { "type": "boolean" @@ -12994,17 +13198,6 @@ ], "type": "object" }, - "TicketWorkflowState": { - "enum": [ - "planning", - "ready", - "queued", - "in_progress", - "done", - "closed" - ], - "type": "string" - }, "TicketWorkspaceNextAction": { "enum": [ "clarify", @@ -16135,7 +16328,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3" + "x-yoi-source-digest": "sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e" }, "openapi": "3.1.0", "paths": { @@ -34048,260 +34241,6 @@ ] } }, - "/api/w/{workspace_id}/tickets/{id}/close": { - "post": { - "operationId": "ticket_close", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrowserCloseTicketRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketDetail" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/complete": { - "post": { - "operationId": "ticket_complete", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/CompleteTicketRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Ticket" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, "/api/w/{workspace_id}/tickets/{id}/dependency-check": { "get": { "operationId": "ticket_dependency_check", @@ -35744,510 +35683,6 @@ ] } }, - "/api/w/{workspace_id}/tickets/{id}/state": { - "post": { - "operationId": "ticket_state_transition", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/BrowserTransitionTicketStateRequest" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketDetail" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/state-changes": { - "post": { - "operationId": "ticket_state_change_add", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketStateUpdate" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/state-fields/{field}": { - "post": { - "operationId": "ticket_state_field_set", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "field", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketStateUpdate" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/state-update": { - "post": { - "operationId": "ticket_state_update", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketStateUpdate" - } - } - }, - "required": true - }, - "responses": { - "200": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/Ticket" - } - } - }, - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, "/api/w/{workspace_id}/tickets/{id}/thread-events": { "post": { "operationId": "ticket_thread_event_add", @@ -36358,246 +35793,6 @@ ] } }, - "/api/w/{workspace_id}/tickets/{id}/workflow-state": { - "post": { - "operationId": "ticket_workflow_state_set", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketStateUpdate" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, - "/api/w/{workspace_id}/tickets/{id}/workflow/close": { - "post": { - "operationId": "ticket_close_record", - "parameters": [ - { - "explode": false, - "in": "path", - "name": "workspace_id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - }, - { - "explode": false, - "in": "path", - "name": "id", - "required": true, - "schema": { - "$ref": "#/components/schemas/string" - }, - "style": "simple" - } - ], - "requestBody": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/TicketCompletion" - } - } - }, - "required": true - }, - "responses": { - "204": { - "description": "Successful response" - }, - "400": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "401": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "403": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "404": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "409": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "422": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - }, - "500": { - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/RepositoryApiError" - } - } - }, - "description": "Error response" - } - }, - "security": [ - { - "bearerAuth": [] - }, - { - "browserSession": [] - } - ] - } - }, "/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready": { "post": { "operationId": "ticket_mark_ready_record", @@ -38876,6 +38071,133 @@ } } } + }, + "/workspaces/{workspace_id}/tickets/{ticket_id}/case": { + "post": { + "operationId": "ticket_case_update", + "parameters": [ + { + "explode": false, + "in": "path", + "name": "workspace_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + }, + { + "explode": false, + "in": "path", + "name": "ticket_id", + "required": true, + "schema": { + "$ref": "#/components/schemas/string" + }, + "style": "simple" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketCaseUpdateRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/TicketSummary" + } + } + }, + "description": "Successful response" + }, + "400": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "401": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "403": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "404": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "409": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "422": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } } } } diff --git a/resources/prompts/common/merge-request.md b/resources/prompts/common/merge-request.md index 3ed7839ec..8fcf44b79 100644 --- a/resources/prompts/common/merge-request.md +++ b/resources/prompts/common/merge-request.md @@ -6,6 +6,6 @@ An open Merge Request keeps one immutable `selector_from` and `selector_to`. Adv A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket `content_digest` (SHA-256 of editable item content) captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Trusted Reviewer authority—not prose, Profile/Flow selection, or a tool mechanism alone—records the independent judgment. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. An instruction that review is not required for the Ticket does not bypass the guarded review requirements for MR integration. -`CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. `CompleteTicket` is an independent authorized decision with a reason and current item/state evidence, not an MR-set/approval gate. Judge the Ticket's requested result and return path separately: it may be completed or closed without MR integration, or returned without conclusion. Do not claim an unmerged result was integrated. +`CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. `TicketCaseUpdate` is an independent reasoned close/reopen/correction decision with current content/state/closure-key evidence, not an MR-set/approval gate. Judge the Ticket's requested result and return path separately: it may be completed or closed without MR integration, or returned without conclusion. Do not claim an unmerged result was integrated. Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a superseded workflow that appends source candidates. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or integration, stop and reread current authority rather than reusing stale evidence. diff --git a/resources/prompts/common/tickets.md b/resources/prompts/common/tickets.md index fd2f9663b..37b703801 100644 --- a/resources/prompts/common/tickets.md +++ b/resources/prompts/common/tickets.md @@ -8,6 +8,6 @@ Keep durable Ticket records centered on user intent, confirmed background, requi Choose a result and return path suited to the request: conversation, an optional Ticket comment, an optional Drive document/artifact when that surface is available, or a repository result. Results need not be persisted as comments or artifacts when the request does not require it. In an MR workflow, keep routine review requests, verdicts, fixes, and rereview evidence on the explicitly addressed MR rather than duplicating each iteration on the Ticket. Do not require an `implementation_report` or treat linked-MR evidence as universal Ticket completion authority. -Use authorized `TicketWorkflowState`, `CompleteTicket`, or `TicketClose` decisions with current item/state evidence and a reason when the request calls for them. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied. +Ticket cases have only `open` (still handled) and `closed` (handling ended for now); open is not execution readiness and closed is not necessarily fulfilled. Use authorized `TicketCaseUpdate` with `close`, `reopen`, or `correct_closure`, an explanation and typed closure reason when the request calls for it. Observe `content_digest`, case state, and nullable `closure_operation_key` from `ShowTicket` for CAS, and use a stable operation key for exact replay. Closure reasons and immutable history are separate from classification tags; tag changes never alter the closure judgment. Reread and reconcile after conflict; never guess a newer observation. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied. Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker. diff --git a/resources/prompts/role/coder.md b/resources/prompts/role/coder.md index 927d9fa9d..b6f634ffa 100644 --- a/resources/prompts/role/coder.md +++ b/resources/prompts/role/coder.md @@ -1,4 +1,4 @@ -You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `CompleteTicket` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority. +You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `TicketCaseUpdate` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority. Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state. diff --git a/resources/prompts/role/orchestrator.md b/resources/prompts/role/orchestrator.md index 84b5fe42f..31f88c771 100644 --- a/resources/prompts/role/orchestrator.md +++ b/resources/prompts/role/orchestrator.md @@ -4,7 +4,7 @@ You are the Ticket Orchestrator role. Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Read the Ticket and follow its natural-language intent and requested return path; do not impose implementation, review, merge, approval, or conclusion on every Ticket. Work may be investigation, analysis, planning, documentation, or code. Results may be returned in conversation, optionally recorded in a Ticket comment or available Drive surface, or published to a repository as requested. Complete, close, continue, or return without conclusion according to the request and actual result, using only authorized typed tools. Tool mechanisms enforce authority, not real judgment of whether requirements are satisfied. -Explicitly start bounded Ticket work with `SpawnTicketWorker`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Recording `inprogress` is not a launch prerequisite: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope. +Explicitly start bounded Ticket work with `SpawnTicketWorker`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Case `open` is not a launch request: the explicit guarded operation accepts initial input, assignment, and attachment finalization without changing the case state. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify the new assignment identity and authoritative unfinished-work acceptance, not retained `current_worker` alone before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope. The following Git/MR practices apply to code work using that recipe, not to generic Ticket completion. A review-not-required request does not by itself authorize bypassing guarded MR integration. @@ -20,7 +20,7 @@ Push the resulting `merge_to` branch through its configured normal push path. Pr After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: accurately report remaining linked MRs and follow the requested continuation or return path; do not claim they were integrated. -If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized completion decision, reread `ShowTicket` and call `CompleteTicket` with `expected_content_digest` from current `content_digest`, expected state, a stable operation key, a reason, and optional supporting references. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends. +If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized case decision, reread `ShowTicket` and call `TicketCaseUpdate` with `expected_content_digest` from current `content_digest`, expected case state, observed nullable `closure_operation_key`, a stable operation key, the typed close/reopen/correction action, an explanation, and optional supporting references. Close with `completed` only if the request is actually fulfilled; other reasons close the case without claiming fulfillment. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends. Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work. diff --git a/resources/prompts/role/ticket-worker.md b/resources/prompts/role/ticket-worker.md index cac0195f2..111160a50 100644 --- a/resources/prompts/role/ticket-worker.md +++ b/resources/prompts/role/ticket-worker.md @@ -2,6 +2,6 @@ You are the assigned Ticket Worker. Carry out the user's requested work and keep Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it. -Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the work meets its acceptance criteria and the request calls for completion, use `CompleteTicket` with a natural-language reason describing the result and optional supporting references. Use authorized `TicketClose` for requested closure when available. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction. +Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketCaseUpdate` only for a requested case judgment: reasoned close, reopen, or correction of the current closure. Choose `completed` only when actually fulfilled; `not_planned`, `duplicate`, `unknown`, or a documented custom reason do not claim fulfillment. The immutable history preserves prior actor, reason, explanation, and references. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected case state plus nullable `closure_operation_key` from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction. Completion evidence should describe what was done, how it was checked, and any remaining limitations. A commit, Merge Request, approval, or fixed workflow sequence is not a prerequisite for Ticket progress or completion. Do not claim work or validation that did not occur. Ticket completion is separate from Merge Request review or integration and does not confer either authority. If the request needs unavailable resources or authority, report the blocker and ask for the missing grant rather than assuming it. diff --git a/tools/state-contract-name-exceptions.json b/tools/state-contract-name-exceptions.json index 53ae6cd1d..5853c109c 100644 --- a/tools/state-contract-name-exceptions.json +++ b/tools/state-contract-name-exceptions.json @@ -559,9 +559,9 @@ ] }, { - "path": "crates/ticket/src/lib.rs", + "path": "crates/ticket/tests/migration.rs", "kind": "saved_data", - "reason": "frozen_counter_receipts_remain_immutable_and_cannot_be_reinterpreted_as_content_requestsの旧completion receipt属性fixture。保存済み旧fingerprintを新content requestの証拠に昇格せず、移行後も履歴を不変に保つ保証。keyを任意語に変えると実在したreceipt形式での回帰検出を失う。", + "reason": "旧Ticket完了receiptの実在したcounter入力keyを新schema7→9移行fixtureへ移設した。過去のfingerprintと全attributeを不変保持し、同じoperation keyを現行content/state/closure-key判断へ再解釈できないことを検証する。旧lib.rsの削除されたfixture例外を同じ1行へ移したもので、現行writer/APIには導入しない。", "lines": [ { "sha256": "060994ddcd0e463815b01cd11b19723fd3652bd4c741fccdb33628e97691f092", diff --git a/web/workspace/deno.json b/web/workspace/deno.json index 5cc5000c9..5a25ed37b 100644 --- a/web/workspace/deno.json +++ b/web/workspace/deno.json @@ -6,7 +6,7 @@ "dev": "deno run -A npm:vite@8.3.2 dev", "dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787", "check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json", - "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", + "test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts", "test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts", "test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts", "test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index a03341919..101b80385 100644 --- a/web/workspace/src/lib/generated/companion-api.ts +++ b/web/workspace/src/lib/generated/companion-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-companion-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/companion-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check diff --git a/web/workspace/src/lib/generated/repository-api.ts b/web/workspace/src/lib/generated/repository-api.ts index 93145145c..e8a9fa21f 100644 --- a/web/workspace/src/lib/generated/repository-api.ts +++ b/web/workspace/src/lib/generated/repository-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-repository-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output // Output: web/workspace/src/lib/generated/repository-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check diff --git a/web/workspace/src/lib/generated/runtime-api.ts b/web/workspace/src/lib/generated/runtime-api.ts index 388f6867b..bd8dadd0f 100644 --- a/web/workspace/src/lib/generated/runtime-api.ts +++ b/web/workspace/src/lib/generated/runtime-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-runtime-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/runtime-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check diff --git a/web/workspace/src/lib/generated/ticket-api.ts b/web/workspace/src/lib/generated/ticket-api.ts index 8818a7ad0..8afefad5e 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,26 +1,22 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/ticket-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; }; -export type BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; }; - export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; }; export type BrowserQueueTicketRequest = { [key: string]: never; }; +export type BrowserTicketState = "open" | "closed"; + export type BrowserTicketTargetEdit = { action: "set"; targets: Array<TicketTarget>; } | { action: "clear"; }; export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report"; -export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed"; - -export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; }; - export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; }; export type ConflictResolution = "none" | "clean" | "conflicts_resolved"; @@ -29,6 +25,8 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe export type DiagnosticSeverity = "info" | "warning" | "error"; +export type ExtensibleTicketStatus = "Open" | "Closed" | { Other: string; }; + export type FindingSeverity = "blocker" | "major" | "minor" | "note"; export type InvalidProjectRecord = { label: string; reason: string; }; @@ -57,7 +55,7 @@ export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string export type MergeStrategy = "fast_forward" | "merge"; -export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketWorkflowState | null; }; +export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketState | null; }; export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; state: string; title: string; updated_at?: string | null; }; @@ -91,7 +89,17 @@ export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contri export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; }; -export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; }; +export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; }; + +export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; author: string; explanation: string; operation_key: string; previous_closure_operation_key?: string | null; references: Array<TicketReference>; }; + +export type TicketCaseUpdateRequest = { action: TicketCaseAction; expected_closure_operation_key: string | null; expected_content_digest: string; expected_state: TicketState; explanation: string; operation_key: string; references?: Array<TicketReference>; }; + +export type TicketClosureJudgment = { at: string; author: string; explanation: string; operation_key: string; reason: TicketClosureReason; references: Array<TicketReference>; }; + +export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; }; + +export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: BrowserTicketState; targets: Array<TicketTarget>; title: string; updated_at?: string | null; }; export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; }; @@ -111,7 +119,9 @@ export type TicketEvidenceSummary = { approved_current_subject: boolean; complet export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; }; -export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; title: string; updated_at?: string | null; workspace_action_priority: string; }; +export type TicketLegacyWorkflowSnapshot = { queued_at?: string | null; queued_by?: string | null; resolution?: string | null; status: string; workflow_state: string; workflow_state_explicit: boolean; }; + +export type TicketListItemSummary = { closure_operation_key?: string | null; current_closure?: TicketClosureJudgment | null; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: BrowserTicketState; title: string; updated_at?: string | null; workspace_action_priority: string; }; export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<TicketListItemSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; }; @@ -133,12 +143,14 @@ export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: st export type TicketSourceRefObservation = { status: "observed"; } | { code: string; status: "unavailable"; } | { status: "not_required"; }; +export type TicketState = "open" | "closed"; + export type TicketStatus = "Open" | "Closed"; +export type TicketSummary = { id: string; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; resource_key?: string | null; slug: string; status: ExtensibleTicketStatus; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; }; + export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; }; export type TicketTargetAccess = "read_only" | "read_write"; export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; }; - -export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed"; diff --git a/web/workspace/src/lib/generated/workdir-api.ts b/web/workspace/src/lib/generated/workdir-api.ts index b40098ed6..d4819b6f9 100644 --- a/web/workspace/src/lib/generated/workdir-api.ts +++ b/web/workspace/src/lib/generated/workdir-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-workdir-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/workdir-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 8814b40e8..d48cb23e8 100644 --- a/web/workspace/src/lib/generated/worker-launch-api.ts +++ b/web/workspace/src/lib/generated/worker-launch-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-worker-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3) +// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e) // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output // Output: web/workspace/src/lib/generated/worker-launch-api.ts // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts index d3b2499d3..000a066c5 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.test.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.test.ts @@ -48,7 +48,7 @@ Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () => id: "ticket-id", resource_key: "T-1", title: "Ticket", - state: "ready", + state: "open", priority: "P2", updated_at: null, queued_by: null, @@ -105,16 +105,23 @@ Deno.test("Ticket Browser accepts generic Worker eligibility and assignments", ( assigned_at: "2026-10-06T00:00:00Z", assigned_by: "user-1", assignment_id: "assignment-1", - principal: { kind: "worker", runtime_id: "runtime-1", worker_id: "worker-1" }, + principal: { + kind: "worker", + runtime_id: "runtime-1", + worker_id: "worker-1", + }, role: "worker", ticket_id: "ticket-id", workspace_id: "workspace-a", }; - assertEquals(parseTicketRoleAssignmentMutationResponse({ + assertEquals( + parseTicketRoleAssignmentMutationResponse({ + assignment, + ticket_id: "ticket-id", + workspace_id: "workspace-a", + }).assignment, assignment, - ticket_id: "ticket-id", - workspace_id: "workspace-a", - }).assignment, assignment); + ); }); function ticketDetailFixture() { @@ -131,6 +138,10 @@ function ticketDetailFixture() { artifacts: [], assignment_diagnostics: [], assignments: [], + closure_history: [], + closure_operation_key: null, + current_closure: null, + legacy_handoff: null, body: "Body", body_truncated: false, current_worker: null, @@ -164,7 +175,7 @@ function ticketDetailFixture() { resolution: null, resource_key: "T-1", risk_flags: [], - state: "planning", + state: "open", targets: [ { repository_key: "docs", @@ -342,11 +353,11 @@ Deno.test("Merge Request summaries require a nullable bounded integration eviden } }); -Deno.test("done Ticket state is preserved when current requirement evidence is incomplete", () => { +Deno.test("closed Ticket state is preserved when current requirement evidence is incomplete", () => { const fixture = ticketDetailFixture(); const parsed = parseTicketDetail({ ...fixture, - state: "done", + state: "closed", evidence: { ...fixture.evidence, missing: ["review_after_rescope"] }, merge_requests: [{ ...mergeRequestSummary, @@ -354,7 +365,7 @@ Deno.test("done Ticket state is preserved when current requirement evidence is i source_ref_observation: { status: "not_required" }, }], }); - assertEquals(parsed.state, "done"); + assertEquals(parsed.state, "closed"); assertEquals(parsed.evidence.complete_for_integration, false); assertEquals(parsed.merge_requests[0]?.review_status, "approved"); }); @@ -440,3 +451,135 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread", "unknown field unexpected", ); }); + +const closure = { + operation_key: "close-1", + reason: "not_planned", + explanation: "Not planned for this scope.", + references: [{ kind: "document", target: "result.md" }], + author: "user:alice", + at: "2026-10-10T00:00:00Z", +}; +const closureHistory = [ + { + ...closure, + reason: undefined, + previous_closure_operation_key: null, + action: { action: "close", reason: "not_planned" }, + }, + { + ...closure, + reason: undefined, + operation_key: "correct-1", + previous_closure_operation_key: "close-1", + action: { action: "correct_closure", reason: { other: "Deferred" } }, + }, + { + ...closure, + reason: undefined, + operation_key: "reopen-1", + previous_closure_operation_key: "correct-1", + action: { action: "reopen" }, + }, +].map(({ reason: _, ...entry }) => ({ + operation_key: entry.operation_key, + previous_closure_operation_key: entry.previous_closure_operation_key, + action: entry.action, + explanation: entry.explanation, + references: entry.references, + author: entry.author, + at: entry.at, +})); +const legacyHandoff = { + workflow_state: "done", + workflow_state_explicit: true, + status: "Closed", + queued_by: "workspace-web", + queued_at: "2026-10-09T00:00:00Z", + resolution: "Historical result, not a current case state", +}; + +Deno.test("case projections retain reasons, actors, references and migration history after reopening", () => { + const fixture = { + ...ticketDetailFixture(), + state: "open", + closure_operation_key: "correct-1", + current_closure: null, + closure_history: closureHistory, + legacy_handoff: legacyHandoff, + }; + const parsed = parseTicketDetail(fixture); + assertEquals(parsed.state, "open"); + assertEquals(parsed.closure_operation_key, "correct-1"); + assertEquals(parsed.closure_history, closureHistory); + assertEquals(parsed.legacy_handoff, legacyHandoff); + assertEquals( + parseTicketDetail({ ...fixture, state: "closed", current_closure: closure }) + .current_closure, + closure, + ); + const list = parseTicketListResponse({ + workspace_id: "workspace-a", + limit: 30, + page, + invalid_records: [], + record_authority: "sqlite", + items: [{ + id: "ticket-id", + resource_key: "T-1", + title: "Ticket", + state: "closed", + priority: "P2", + record_source: "sqlite", + workspace_action_priority: "background", + closure_operation_key: "close-1", + current_closure: closure, + legacy_handoff: legacyHandoff, + }], + }); + assertEquals(list.items[0].current_closure, closure); + assertEquals(list.items[0].legacy_handoff, legacyHandoff); +}); + +Deno.test("case decoders reject legacy current states and malformed closure judgments", () => { + const fixture = ticketDetailFixture(); + for (const state of ["planning", "ready", "queued", "inprogress", "done"]) { + assertThrows( + () => parseTicketDetail({ ...fixture, state }), + "state is invalid", + ); + } + for (const reason of ["done", { other: 1 }, { other: "ok", extra: true }]) { + assertThrows( + () => + parseTicketDetail({ + ...fixture, + current_closure: { ...closure, reason }, + }), + "Ticket detail.current_closure.reason", + ); + } + for ( + const action of ["reopen", { action: "close" }, { + action: "reopen", + reason: "completed", + }, { action: "complete", reason: "completed" }] + ) { + assertThrows( + () => + parseTicketDetail({ + ...fixture, + closure_history: [{ ...closureHistory[0], action }], + }), + "Ticket detail.closure_history[0].action", + ); + } + assertThrows( + () => parseTicketDetail({ ...fixture, closure_history: {} }), + "closure_history must be an array", + ); + assertThrows( + () => parseTicketDetail({ ...fixture, closure_operation_key: 1 }), + "closure_operation_key must be a string", + ); +}); diff --git a/web/workspace/src/lib/workspace/api/ticket-browser.ts b/web/workspace/src/lib/workspace/api/ticket-browser.ts index da2ce4ba7..95bc699f9 100644 --- a/web/workspace/src/lib/workspace/api/ticket-browser.ts +++ b/web/workspace/src/lib/workspace/api/ticket-browser.ts @@ -1,4 +1,5 @@ import type { + BrowserTicketState, InvalidProjectRecord, MergeRequestDetailResponse, MergeRequestLinkedTicketResponse, @@ -20,6 +21,10 @@ import type { TicketActionEligibility, TicketAssignmentPrincipal, TicketAssignmentSummary, + TicketCaseAction, + TicketCaseHistoryEntry, + TicketClosureJudgment, + TicketClosureReason, TicketDetail, TicketDetailDerivedRelation, TicketDetailRelation, @@ -29,14 +34,17 @@ import type { TicketEventDetail, TicketEvidenceEvent, TicketEvidenceSummary, + TicketLegacyWorkflowSnapshot, TicketListItemSummary, TicketListResponse, TicketMergeRequestSummary, TicketQueueOutcome, TicketRef, + TicketReference, TicketRoleAssignmentMutationResponse, TicketRoleAssignmentRecord, TicketRoleAssignmentSummary, + TicketSummary, TicketTarget, } from "#lib/generated/ticket-api.ts"; @@ -183,12 +191,208 @@ function parseInvalidProjectRecord( }; } +function parseTicketState(value: unknown, label: string): BrowserTicketState { + if (value !== "open" && value !== "closed") { + throw new Error(`${label} is invalid`); + } + return value; +} + +function parseClosureReason( + value: unknown, + label: string, +): TicketClosureReason { + if ( + typeof value === "string" && + ["completed", "not_planned", "duplicate", "unknown"].includes(value) + ) { + return value as TicketClosureReason; + } + const item = object(value, label); + exact(item, ["other"], label); + return { other: string(item.other, `${label}.other`) }; +} + +function parseCaseAction(value: unknown, label: string): TicketCaseAction { + const item = object(value, label); + if (item.action === "reopen") { + exact(item, ["action"], label); + return { action: "reopen" }; + } + if (item.action !== "close" && item.action !== "correct_closure") { + throw new Error(`${label}.action is invalid`); + } + exact(item, ["action", "reason"], label); + return { + action: item.action, + reason: parseClosureReason(item.reason, `${label}.reason`), + }; +} + +function parseReference(value: unknown, label: string): TicketReference { + const item = object(value, label); + exact(item, ["kind", "target"], label); + return { + kind: string(item.kind, `${label}.kind`), + target: string(item.target, `${label}.target`), + }; +} + +function parseClosure(value: unknown, label: string): TicketClosureJudgment { + const item = object(value, label); + exact(item, [ + "operation_key", + "reason", + "explanation", + "references", + "author", + "at", + ], label); + return { + operation_key: string(item.operation_key, `${label}.operation_key`), + reason: parseClosureReason(item.reason, `${label}.reason`), + explanation: string(item.explanation, `${label}.explanation`), + references: array(item.references, `${label}.references`, parseReference), + author: string(item.author, `${label}.author`), + at: string(item.at, `${label}.at`), + }; +} + +function parseCaseHistory( + value: unknown, + label: string, +): TicketCaseHistoryEntry { + const item = object(value, label); + exact(item, [ + "operation_key", + "previous_closure_operation_key", + "action", + "explanation", + "references", + "author", + "at", + ], label); + return { + operation_key: string(item.operation_key, `${label}.operation_key`), + previous_closure_operation_key: optionalNullableString( + item.previous_closure_operation_key, + `${label}.previous_closure_operation_key`, + ), + action: parseCaseAction(item.action, `${label}.action`), + explanation: string(item.explanation, `${label}.explanation`), + references: array(item.references, `${label}.references`, parseReference), + author: string(item.author, `${label}.author`), + at: string(item.at, `${label}.at`), + }; +} + +function parseLegacyHandoff( + value: unknown, + label: string, +): TicketLegacyWorkflowSnapshot { + const item = object(value, label); + exact(item, [ + "workflow_state", + "workflow_state_explicit", + "status", + "queued_by", + "queued_at", + "resolution", + ], label); + return { + workflow_state: string(item.workflow_state, `${label}.workflow_state`), + workflow_state_explicit: boolean( + item.workflow_state_explicit, + `${label}.workflow_state_explicit`, + ), + status: string(item.status, `${label}.status`), + queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), + queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), + resolution: optionalNullableString(item.resolution, `${label}.resolution`), + }; +} + +function caseProjection(item: Record<string, unknown>, label: string) { + return { + closure_operation_key: optionalNullableString( + item.closure_operation_key, + `${label}.closure_operation_key`, + ), + current_closure: item.current_closure === undefined + ? undefined + : item.current_closure === null + ? null + : parseClosure(item.current_closure, `${label}.current_closure`), + legacy_handoff: item.legacy_handoff === undefined + ? undefined + : item.legacy_handoff === null + ? null + : parseLegacyHandoff(item.legacy_handoff, `${label}.legacy_handoff`), + }; +} + +export function parseTicketSummary(value: unknown): TicketSummary { + const label = "Ticket summary"; + const item = object(value, label); + exact(item, [ + "id", + "kind", + "labels", + "priority", + "queued_at", + "queued_by", + "readiness", + "resource_key", + "slug", + "status", + "title", + "updated_at", + "workflow_state", + "workflow_state_explicit", + ], label); + let status: TicketSummary["status"]; + if (item.status === "Open" || item.status === "Closed") status = item.status; + else { + const other = object(item.status, `${label}.status`); + exact(other, ["Other"], `${label}.status`); + status = { Other: string(other.Other, `${label}.status.Other`) }; + } + return { + id: string(item.id, `${label}.id`), + kind: string(item.kind, `${label}.kind`), + labels: strings(item.labels, `${label}.labels`), + priority: string(item.priority, `${label}.priority`), + queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), + queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), + readiness: optionalNullableString(item.readiness, `${label}.readiness`), + resource_key: optionalNullableString( + item.resource_key, + `${label}.resource_key`, + ), + slug: string(item.slug, `${label}.slug`), + status, + title: string(item.title, `${label}.title`), + updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), + workflow_state: parseTicketState( + item.workflow_state, + `${label}.workflow_state`, + ), + workflow_state_explicit: boolean( + item.workflow_state_explicit, + `${label}.workflow_state_explicit`, + ), + }; +} + function parseTicketListItem( value: unknown, label: string, ): TicketListItemSummary { const item = object(value, label); exact(item, [ + "closure_operation_key", + "current_closure", + "legacy_handoff", "id", "priority", "queued_at", @@ -201,13 +405,14 @@ function parseTicketListItem( "workspace_action_priority", ], label); return { + ...caseProjection(item, label), id: string(item.id, `${label}.id`), priority: string(item.priority, `${label}.priority`), queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`), queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`), record_source: string(item.record_source, `${label}.record_source`), resource_key: string(item.resource_key, `${label}.resource_key`), - state: string(item.state, `${label}.state`), + state: parseTicketState(item.state, `${label}.state`), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), workspace_action_priority: string( @@ -727,6 +932,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { "artifacts", "assignment_diagnostics", "assignments", + "closure_history", "body", "body_truncated", "created_at", @@ -735,6 +941,9 @@ export function parseTicketDetail(value: unknown): TicketDetail { "event_page", "events", "evidence", + "closure_operation_key", + "current_closure", + "legacy_handoff", "id", "implementation_reports", "content_digest", @@ -756,6 +965,12 @@ export function parseTicketDetail(value: unknown): TicketDetail { "updated_at", ], label); return { + ...caseProjection(item, label), + closure_history: array( + item.closure_history, + `${label}.closure_history`, + parseCaseHistory, + ), action_eligibility: parseActionEligibility( item.action_eligibility, `${label}.action_eligibility`, @@ -820,7 +1035,7 @@ export function parseTicketDetail(value: unknown): TicketDetail { resolution: optionalNullableString(item.resolution, `${label}.resolution`), resource_key: string(item.resource_key, `${label}.resource_key`), risk_flags: strings(item.risk_flags, `${label}.risk_flags`), - state: string(item.state, `${label}.state`), + state: parseTicketState(item.state, `${label}.state`), targets: array(item.targets, `${label}.targets`, parseTicketTarget), title: string(item.title, `${label}.title`), updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`), diff --git a/web/workspace/src/lib/workspace/api/ticket-case.test.ts b/web/workspace/src/lib/workspace/api/ticket-case.test.ts new file mode 100644 index 000000000..1cb7d2601 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-case.test.ts @@ -0,0 +1,116 @@ +declare const Deno: { + test(name: string, fn: () => Promise<void> | void): void; +}; +function assertEquals(actual: unknown, expected: unknown): void { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error( + `Expected ${JSON.stringify(expected)}, received ${ + JSON.stringify(actual) + }`, + ); + } +} +async function assertRejects(operation: () => Promise<unknown>): Promise<void> { + try { + await operation(); + } catch (error) { + if (error instanceof Error && error.message.includes("409")) return; + throw error; + } + throw new Error("Expected stale CAS rejection"); +} +import type { TicketCaseAction } from "#lib/generated/ticket-api.ts"; +import { ticketCaseRequest, updateTicketCase } from "./ticket-case.ts"; + +const observed = { + content_digest: "a".repeat(64), + state: "open", + closure_operation_key: "last-correction", +} as const; +const summary = { + id: "ticket-1", + resource_key: "T-1", + slug: "case", + title: "Case", + kind: "task", + labels: [], + priority: "P2", + status: "Closed", + workflow_state: "closed", + workflow_state_explicit: true, +}; + +Deno.test("case actions carry content/state/last-closure CAS without browser author or unrelated lifecycle fields", async () => { + const actions: TicketCaseAction[] = [ + ...["completed", "not_planned", "duplicate", "unknown", { + other: "Deferred", + }].map((reason) => ({ action: "close", reason } as TicketCaseAction)), + { action: "reopen" }, + { action: "correct_closure", reason: "unknown" }, + ]; + const original = globalThis.fetch; + try { + for (const action of actions) { + const request = ticketCaseRequest( + observed, + action, + " Case judgment ", + "operation-1", + ); + assertEquals(request, { + expected_content_digest: observed.content_digest, + expected_state: "open", + expected_closure_operation_key: "last-correction", + operation_key: "operation-1", + action, + explanation: "Case judgment", + references: [], + }); + globalThis.fetch = (input, init) => { + assertEquals(input, "/workspaces/workspace%20one/tickets/T%2F1/case"); + const options = init as { method?: string; body?: string }; + assertEquals(options.method, "POST"); + assertEquals(JSON.parse(String(options.body)), request); + return Promise.resolve(Response.json(summary)); + }; + assertEquals( + (await updateTicketCase("workspace one", "T/1", request)) + .workflow_state, + "closed", + ); + } + } finally { + globalThis.fetch = original; + } + assertEquals( + ticketCaseRequest( + { ...observed, closure_operation_key: null }, + { action: "close", reason: "unknown" }, + "Close", + "first", + ).expected_closure_operation_key, + null, + ); +}); + +Deno.test("case API propagates stale CAS failure rather than reporting success", async () => { + const original = globalThis.fetch; + globalThis.fetch = () => + Promise.resolve(Response.json({ message: "stale case" }, { status: 409 })); + try { + await assertRejects(() => + updateTicketCase( + "w", + "T-1", + ticketCaseRequest( + observed, + { action: "reopen" }, + "Reopen", + "operation-1", + ), + ) + ); + } finally { + globalThis.fetch = original; + } +}); diff --git a/web/workspace/src/lib/workspace/api/ticket-case.ts b/web/workspace/src/lib/workspace/api/ticket-case.ts new file mode 100644 index 000000000..d8030d3d8 --- /dev/null +++ b/web/workspace/src/lib/workspace/api/ticket-case.ts @@ -0,0 +1,47 @@ +import type { + TicketCaseAction, + TicketCaseUpdateRequest, + TicketDetail, + TicketSummary, +} from "#lib/generated/ticket-api.ts"; +import { workspaceApiJsonWithBody } from "./http.ts"; +import { + parseTicketSummary, + TICKET_BROWSER_API_MAX_RESPONSE_BYTES, +} from "./ticket-browser.ts"; + +export function ticketCaseRequest( + ticket: Pick< + TicketDetail, + "content_digest" | "state" | "closure_operation_key" + >, + action: TicketCaseAction, + explanation: string, + operationKey: string, +): TicketCaseUpdateRequest { + return { + expected_content_digest: ticket.content_digest, + expected_state: ticket.state, + // The last judgment key survives reopening; do not derive it from current_closure. + expected_closure_operation_key: ticket.closure_operation_key ?? null, + operation_key: operationKey, + action, + explanation: explanation.trim(), + references: [], + }; +} + +export function updateTicketCase( + workspaceId: string, + ticketId: string, + request: TicketCaseUpdateRequest, +): Promise<TicketSummary> { + return workspaceApiJsonWithBody( + `/workspaces/${encodeURIComponent(workspaceId)}/tickets/${ + encodeURIComponent(ticketId) + }/case`, + { method: "POST", body: JSON.stringify(request) }, + parseTicketSummary, + TICKET_BROWSER_API_MAX_RESPONSE_BYTES, + ); +} diff --git a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts index 45cb8ed87..df1dc3d0f 100644 --- a/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts +++ b/web/workspace/src/lib/workspace/console/worker-console.ui.test.ts @@ -404,12 +404,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as "Tickets list should fetch lightweight paginated summaries for each Kanban lane", ); assert( - ticketPanelModel.includes('label: "Ready + Planning"') && - ticketPanelModel.includes('label: "In progress + Queued"') && - ticketPanelModel.includes('label: "Done + Closed"') && + ticketPanelModel.includes('label: "Open"') && + ticketPanelModel.includes('label: "Closed"') && ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") && ticketPanelModel.includes("nextTicketLaneVisibleCount"), - "Ticket Kanban should combine related states into independent 30-item display windows", + "Ticket Kanban should separate cases into independent 30-item display windows", ); assert( generatedTicketApi.includes( @@ -428,7 +427,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as ); assert( ticketDetailLoad.includes("/repositories") && - ticketDetailPage.includes('mutate("state", "/state"') && + ticketDetailPage.includes("ticketCaseRequest(ticket, action") && ticketDetailPage.includes("async function queueTicket") && ticketDetailPage.includes("const path = ticketPath") && ticketDetailPage.includes("`${path}/queue`") && @@ -436,7 +435,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as ticketDetailPage.includes("mergeRequest.selector_from") && ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") && ticketDetailPage.includes("mergeRequestPagePath") && - ticketDetailPage.includes('mutate("close", "/close"') && + ticketDetailPage.includes("await updateTicketCase(") && ticketDetailPage.includes("mutateAssignment") && ticketDetailPage.includes("can_start_manual_worker") && ticketDetailPage.includes("ticket.relations.outgoing"), diff --git a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts index 6e5327c01..242695ef2 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.test-fixtures.ts @@ -10,29 +10,35 @@ export const fixturePage = (returned: number, more = false) => ({ }); const updated = (hour: number) => `2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`; -export function fixtureTicket(key: string, state: string, hour = 10) { +export function fixtureTicket( + key: string, + state: "open" | "closed", + hour = 10, +) { return { id: `internal-${key}`, resource_key: key, title: key === "T-101" ? "Restore queued Worker submissions" : `${state} ticket ${key}`, + closure_operation_key: null, + current_closure: null, + legacy_handoff: null, state, priority: "normal", queued_at: null, queued_by: null, updated_at: updated(hour), record_source: "fixture", - workspace_action_priority: state === "inprogress" || state === "queued" - ? "active_work" - : "background", + workspace_action_priority: state === "open" ? "active_work" : "background", }; } export function fixtureDetail(key = "T-101") { - const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued"); + const summary = fixtureTicket(key, "open"); const { workspace_action_priority: _, ...ticket } = summary; return { ...ticket, + closure_history: [], content_digest: "a".repeat(64), created_at: updated(1), readiness: null, @@ -87,7 +93,7 @@ export function fixtureDetail(key = "T-101") { blockers: key === "T-101" ? [{ blocking_resource_key: "T-99", - blocking_state: "ready", + blocking_state: "open", blocking_ticket: "internal-T-99", note: null, reason_kind: "dependency", @@ -108,11 +114,13 @@ export function dashboardFixture( const state = search.get("states")!; const items = empty ? [] - : state === "inprogress,queued" - ? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")] - : state === "done" - ? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)] - : [fixtureTicket("T-105", "closed", 14)]; + : state === "open" + ? [fixtureTicket("T-101", "open"), fixtureTicket("T-102", "open")] + : [ + fixtureTicket("T-105", "closed", 14), + fixtureTicket("T-103", "closed", 12), + fixtureTicket("T-104", "closed", 7), + ]; if (long && items[0]) { items[0].title = "長いTicket名 — ".repeat(8) + "preserve-the-full-title-without-horizontal-overflow"; diff --git a/web/workspace/src/lib/workspace/home/dashboard.ts b/web/workspace/src/lib/workspace/home/dashboard.ts index 12a0c1a17..b50df4bf2 100644 --- a/web/workspace/src/lib/workspace/home/dashboard.ts +++ b/web/workspace/src/lib/workspace/home/dashboard.ts @@ -35,9 +35,7 @@ export type Dashboard = { }; const states: Record<string, string> = { - inprogress: "In progress", - queued: "Queued", - done: "Done", + open: "Open", closed: "Closed", active: "Active", completed: "Completed", @@ -113,8 +111,7 @@ export function loadDashboard( })}`, parseTicketListResponse, ); - const activeResult = tickets("inprogress,queued"); - const doneResult = tickets("done"); + const activeResult = tickets("open"); const closedResult = tickets("closed"); const objectivesResult = get( `/objectives?limit=${HOME_LIMIT}`, @@ -179,7 +176,7 @@ export function loadDashboard( } const current = detail.data; // A concurrent transition can remove a Ticket from this snapshot's scope. - if (!["inprogress", "queued"].includes(current.state)) { + if (current.state !== "open") { return { changed: true }; } const blockers = current.relations.blockers; @@ -218,17 +215,14 @@ export function loadDashboard( : result.data.page.has_more || result.data.page.source_truncated ? `Showing ${ Math.min(result.data.items.length, HOME_LIMIT) - } active tickets, with in-progress work first.` + } open tickets.` : undefined, }; }), - recent: Promise.all([doneResult, closedResult, objectivesResult]).then(( - [done, closed, objectives], + recent: Promise.all([closedResult, objectivesResult]).then(( + [closed, objectives], ): DashboardFeed => ({ rows: recentRows([ - ...(done.data?.items ?? []).map((ticket) => - ticketRow(workspaceId, ticket) - ), ...(closed.data?.items ?? []).map((ticket) => ticketRow(workspaceId, ticket) ), @@ -243,21 +237,17 @@ export function loadDashboard( })), ]), errors: [ - ...(!done.data ? [failure("Done tickets", done.error)] : []), ...(!closed.data ? [failure("Closed tickets", closed.error)] : []), ...(!objectives.data ? [failure("Objectives", objectives.error)] : []), - ...([done.data, closed.data, objectives.data].some((data) => + ...([closed.data, objectives.data].some((data) => data?.invalid_records.length ) ? ["Some records could not be read."] : []), ], - notice: - [done.data, closed.data, objectives.data].some((data) => - data?.items.length - ) - ? "Up to 5 latest updates to done / closed tickets and Objectives." - : undefined, + notice: [closed.data, objectives.data].some((data) => data?.items.length) + ? "Up to 5 latest updates to closed tickets and Objectives." + : undefined, })), }; } diff --git a/web/workspace/src/lib/workspace/merge-request-status.test.ts b/web/workspace/src/lib/workspace/merge-request-status.test.ts index c02bac2ce..6ead21155 100644 --- a/web/workspace/src/lib/workspace/merge-request-status.test.ts +++ b/web/workspace/src/lib/workspace/merge-request-status.test.ts @@ -86,10 +86,10 @@ Deno.test("observed live source movement requires fresh review but an unchanged ); }); -Deno.test("done Ticket can retain immutable approval while current requirement evidence is incomplete", () => { +Deno.test("closed Ticket can retain immutable approval while current requirement evidence is incomplete", () => { const ticket = parseTicketDetail({ ...fixtureDetail(), - state: "done", + state: "closed", evidence: { ...fixtureDetail().evidence, has_merge_request: true, @@ -107,7 +107,7 @@ Deno.test("done Ticket can retain immutable approval while current requirement e source_ref_observation: { status: "not_required" }, }], }); - assertEquals(ticket.state, "done"); + assertEquals(ticket.state, "closed"); assertEquals(ticket.evidence.complete_for_integration, false); assertEquals( currentRequirementApprovalStatus(ticket.evidence), @@ -128,14 +128,14 @@ Deno.test("done Ticket can retain immutable approval while current requirement e currentRequirementApprovalStatus(recovered.evidence), "approved", ); - assertEquals(recovered.state, "done"); + assertEquals(recovered.state, "closed"); assertEquals( summarySourceReviewStatus(ticket.merge_requests[0]!), "Integration approval recorded for immutable merged source source-1.", ); }); -Deno.test("Ticket route separates current requirement evidence from immutable integration approval without cancelling done", async () => { +Deno.test("Ticket route separates current requirement evidence from immutable integration approval without changing case closure", async () => { const source = await Deno.readTextFile( new URL( "../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte", @@ -144,15 +144,15 @@ Deno.test("Ticket route separates current requirement evidence from immutable in ); for ( const required of [ - "Current requirement evidence", + "MR requirement evidence", "ticket.evidence.complete_for_integration", "Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}", "ticket.evidence.missing", "summarySourceReviewStatus(mergeRequest)", "Immutable integration evidence", "Integration evidence error:", - '{#if ticket.state === "done"}', - "Incomplete current requirement evidence does not cancel recorded completion.", + '{#if ticket.state === "closed"}', + "Incomplete MR evidence does not alter the recorded case judgment.", ] ) { if (!source.includes(required)) { diff --git a/web/workspace/src/lib/workspace/styles/tickets.css b/web/workspace/src/lib/workspace/styles/tickets.css index ac8821c80..cfc6046aa 100644 --- a/web/workspace/src/lib/workspace/styles/tickets.css +++ b/web/workspace/src/lib/workspace/styles/tickets.css @@ -131,7 +131,7 @@ .ticket-kanban { display: grid; align-items: stretch; - grid-template-columns: repeat(3, minmax(18rem, 1fr)); + grid-template-columns: repeat(2, minmax(18rem, 1fr)); gap: var(--space-3); overflow-x: auto; padding-bottom: var(--space-3); @@ -168,13 +168,10 @@ border-radius: 50%; background: var(--text-muted); } - .ticket-lane[data-state="ready-planning"] .ticket-state-dot { + .ticket-lane[data-state="open"] .ticket-state-dot { background: var(--success); } - .ticket-lane[data-state="inprogress-queued"] .ticket-state-dot { - background: var(--warning); - } - .ticket-lane[data-state="done-closed"] .ticket-state-dot { + .ticket-lane[data-state="closed"] .ticket-state-dot { background: var(--accent); } .ticket-lane-count { diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts index 63dd10a71..e472b4076 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.test.ts @@ -23,56 +23,26 @@ function assertEquals<T>(actual: T, expected: T): void { } } -Deno.test("ticketLanes combines workflow states and sorts by state then update time", () => { +Deno.test("ticketLanes separates open and closed cases and orders updates deterministically", () => { const tickets = [ { - id: "planning", - state: "planning", - title: "Planning", - updated_at: "2026-07-30T12:00:00Z", - }, - { - id: "ready-old", - state: "ready", - title: "Ready old", + id: "old", + state: "open", + title: "Open old", updated_at: "2026-07-29T12:00:00Z", }, { - id: "ready-new", - state: "ready", - title: "Ready new", + id: "new", + state: "open", + title: "Open new", updated_at: "2026-07-30T12:00:00Z", }, - { id: "queued", state: "queued", title: "Queued", updated_at: null }, - { - id: "inprogress", - state: "inprogress", - title: "In progress", - updated_at: null, - }, { id: "closed", state: "closed", title: "Closed", updated_at: null }, - { id: "done", state: "done", title: "Done", updated_at: null }, ] as TicketSummary[]; - const lanes = ticketLanes(tickets); - assertEquals(lanes.map((lane) => lane.id), [ - "ready-planning", - "inprogress-queued", - "done-closed", - ]); - assertEquals(lanes[0].tickets.map((ticket) => ticket.id), [ - "ready-new", - "ready-old", - "planning", - ]); - assertEquals(lanes[1].tickets.map((ticket) => ticket.id), [ - "inprogress", - "queued", - ]); - assertEquals(lanes[2].tickets.map((ticket) => ticket.id), [ - "done", - "closed", - ]); + assertEquals(lanes.map((lane) => lane.id), ["open", "closed"]); + assertEquals(lanes[0].tickets.map((ticket) => ticket.id), ["new", "old"]); + assertEquals(lanes[1].tickets.map((ticket) => ticket.id), ["closed"]); }); Deno.test("ticket lane visibility advances in bounded pages of 30", () => { diff --git a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts index fd246e20e..1a2a388d4 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-panel.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-panel.ts @@ -4,35 +4,12 @@ import type { TicketListItemSummary, } from "#lib/generated/ticket-api.ts"; -export const TICKET_STATES = [ - "planning", - "ready", - "queued", - "inprogress", - "done", - "closed", -] as const; - +export const TICKET_STATES = ["open", "closed"] as const; export type TicketState = (typeof TICKET_STATES)[number]; - export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse; - const LANE_DEFINITIONS = [ - { - id: "ready-planning", - label: "Ready + Planning", - states: ["ready", "planning"], - }, - { - id: "inprogress-queued", - label: "In progress + Queued", - states: ["inprogress", "queued"], - }, - { - id: "done-closed", - label: "Done + Closed", - states: ["done", "closed"], - }, + { id: "open", label: "Open", states: ["open"] }, + { id: "closed", label: "Closed", states: ["closed"] }, ] as const; export const TICKET_LANE_PAGE_SIZE = 30; @@ -44,15 +21,6 @@ export type TicketCardSummary = Pick< "id" | "resource_key" | "title" | "state" | "priority" | "updated_at" >; -const STATE_SORT_ORDER = new Map<string, number>([ - ["ready", 0], - ["planning", 1], - ["inprogress", 0], - ["queued", 1], - ["done", 0], - ["closed", 1], -]); - export type TicketLane = { id: TicketLaneId; label: string; @@ -77,9 +45,6 @@ export function sortTickets( tickets: TicketCardSummary[], ): TicketCardSummary[] { return [...tickets].sort((left, right) => { - const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) - - (STATE_SORT_ORDER.get(right.state) ?? 99); - if (stateDelta !== 0) return stateDelta; const updatedDelta = updatedAt(right) - updatedAt(left); if (updatedDelta !== 0) return updatedDelta; return left.id.localeCompare(right.id); diff --git a/web/workspace/src/routes/w/[workspaceId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/+page.svelte index 18d93b0a5..2a8772405 100644 --- a/web/workspace/src/routes/w/[workspaceId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/+page.svelte @@ -31,8 +31,8 @@ {#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if} <div class="home-columns"> <DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} /> - <DashboardSection id="home-active" title="In progress" empty="No in-progress or queued tickets." result={data.dashboard.active} /> - <DashboardSection id="home-recent" title="Recent updates" empty="No done / closed tickets or Objectives yet." result={data.dashboard.recent} /> + <DashboardSection id="home-active" title="Open tickets" empty="No open tickets." result={data.dashboard.active} /> + <DashboardSection id="home-recent" title="Recent updates" empty="No closed tickets or Objectives yet." result={data.dashboard.recent} /> </div> </div> diff --git a/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts index 24fe3ecb3..32cc979a1 100644 --- a/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts +++ b/web/workspace/src/routes/w/[workspaceId]/home.browser.spec.ts @@ -86,7 +86,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl ); await Promise.all(Object.values(result!.dashboard)); expect(depends).toHaveBeenCalledWith("workspace:home"); - expect(fetch).toHaveBeenCalledTimes(7); + expect(fetch).toHaveBeenCalledTimes(6); for (const [path, init] of fetch.mock.calls) { const url = new URL(String(path), "https://example.test"); expect(init?.method ?? "GET").toBe("GET"); @@ -96,7 +96,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl expect(url.searchParams.get("limit")).toBe("5"); } } - expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done"))) + expect(fetch.mock.calls.some(([url]) => String(url).includes("states=open"))) .toBe(true); expect( fetch.mock.calls.some(([url]) => String(url).includes("states=closed")), @@ -152,7 +152,7 @@ test("one recent source failure preserves the other records and does not assert expect((await screen.findByRole("alert")).textContent).toContain( "Objectives", ); - expect(screen.queryByText("No done / closed tickets or Objectives yet.")) + expect(screen.queryByText("No closed tickets or Objectives yet.")) .toBeNull(); }); @@ -169,7 +169,7 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti const fallback = fixtureFetch(); const fetchFn: typeof fetch = (path, init) => String(path).endsWith("/tickets/T-101") - ? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" })) + ? Promise.resolve(Response.json({ ...fixtureDetail(), state: "closed" })) : fallback(path, init); const feed = await data("home-owner", fetchFn).dashboard.active; expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]); @@ -179,9 +179,9 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti test("empty resources have scoped empty states and no fabricated totals", async () => { mount(data("home-empty")); expect(await screen.findByText("No open Merge Requests.")).toBeTruthy(); - expect(await screen.findByText("No in-progress or queued tickets.")) + expect(await screen.findByText("No open tickets.")) .toBeTruthy(); - expect(await screen.findByText("No done / closed tickets or Objectives yet.")) + expect(await screen.findByText("No closed tickets or Objectives yet.")) .toBeTruthy(); expect(screen.queryAllByRole("link")).toHaveLength(0); }); diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte index 53b080f00..383828e4a 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.svelte @@ -168,7 +168,7 @@ labels: [], body: createBody, risk_flags: [], - workflow_state: "planning", + workflow_state: "open", targets: normalizedCreateTargets(), }; try { @@ -297,7 +297,7 @@ </p> {:else if !orchestrator.data?.online} <p class="workspace-callout"> - Orchestrator is offline. Requests can be queued; progress and results can still be recorded directly. + Orchestrator is offline. Requests can be queued; case decisions and results can still be recorded directly. </p> {/if} diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts index b94d509c7..9721b4e1d 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/+page.ts @@ -9,11 +9,7 @@ import type { TicketListResponse } from "#lib/generated/ticket-api.ts"; import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts"; import type { PageLoad } from "./$types"; -const LANE_STATES = { - "ready-planning": ["ready", "planning"], - "inprogress-queued": ["inprogress", "queued"], - "done-closed": ["done", "closed"], -} as const; +const LANE_STATES = { open: ["open"], closed: ["closed"] } as const; export type TicketLaneId = keyof typeof LANE_STATES; @@ -48,15 +44,13 @@ async function loadLane( export const load: PageLoad = async ({ fetch, params }) => { const workspaceId = params.workspaceId; const [ - readyPlanning, - inprogressQueued, - doneClosed, + open, + closed, orchestrator, repositories, ] = await Promise.all([ - loadLane(fetch, workspaceId, "ready-planning"), - loadLane(fetch, workspaceId, "inprogress-queued"), - loadLane(fetch, workspaceId, "done-closed"), + loadLane(fetch, workspaceId, "open"), + loadLane(fetch, workspaceId, "closed"), loadJson<WorkspaceOrchestratorStatus>( fetch, workspaceApiPath(workspaceId, "/orchestrator"), @@ -69,9 +63,8 @@ export const load: PageLoad = async ({ fetch, params }) => { return { workspaceId, ticketLanes: { - "ready-planning": readyPlanning, - "inprogress-queued": inprogressQueued, - "done-closed": doneClosed, + open, + closed, }, orchestrator, repositories, diff --git a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte index af878eaf7..b2536cc0c 100644 --- a/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte +++ b/web/workspace/src/routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte @@ -12,16 +12,18 @@ parseTicketQueueOutcome, parseTicketRoleAssignmentMutationResponse, } from "#lib/workspace/api/ticket-browser.ts"; + import { ticketCaseRequest, updateTicketCase } from "#lib/workspace/api/ticket-case.ts"; import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts"; import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts"; import { relationLabel, - TICKET_STATES, ticketWorkerLaunchHref, type WorkspaceOrchestratorStatus, } from "#lib/workspace/tickets/ticket-panel.ts"; import type { ApiResult } from "#lib/workspace/api/http.ts"; import type { + TicketCaseAction, + TicketClosureReason, TicketTarget, TicketTargetAccess, } from "#lib/generated/ticket-api.ts"; @@ -37,7 +39,6 @@ access: TicketTargetAccess; }; - const MUTABLE_TICKET_STATES = TICKET_STATES; function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] { return targets.map((target) => ({ @@ -78,12 +79,11 @@ let targetDrafts = $state<EditableTicketTarget[]>( editableTargets(loadedTicket.targets), ); - let nextState = $state(loadedTicket.state); let transitionReason = $state(""); - let progressBody = $state(""); + let closureReason = $state("unknown"); + let otherClosureReason = $state(""); let threadRole = $state("comment"); let threadBody = $state(""); - let resolution = $state(""); let busy = $state<string | null>(null); let errorMessage = $state<string | null>(null); let queueMessage = $state<string | null>(null); @@ -139,17 +139,16 @@ editTitle = updatedTicket.title; editBody = updatedTicket.body; targetDrafts = editableTargets(updatedTicket.targets); - nextState = updatedTicket.state; } function resetTicketView(updatedTicket: TicketDetail): void { applyTicket(updatedTicket); editing = false; transitionReason = ""; - progressBody = ""; + closureReason = "unknown"; + otherClosureReason = ""; threadRole = "comment"; threadBody = ""; - resolution = ""; busy = null; errorMessage = null; queueMessage = null; @@ -363,7 +362,7 @@ } async function markReady() { - if (ticket.state !== "planning" || busy) return; + if (ticket.state !== "open" || busy) return; readyOperationKey ??= crypto.randomUUID(); if ( await mutate("ready", "/ready", { @@ -376,18 +375,33 @@ } } - async function transition(event: SubmitEvent) { - event.preventDefault(); - if ( - await mutate("state", "/state", { - state: nextState, - operation_key: crypto.randomUUID(), - expected_content_digest: ticket.content_digest, - expected_state: ticket.state, - reason: transitionReason.trim(), - body: progressBody.trim() || null, - }) - ) { transitionReason = ""; progressBody = ""; } + function selectedClosureReason(): TicketClosureReason { + return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason; + } + + async function caseDecision(action: TicketCaseAction): Promise<void> { + if (busy || !transitionReason.trim()) return; + const generation = routeGeneration; + const path = ticketPath; + const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID()); + busy = action.action; + errorMessage = null; + try { + await updateTicketCase(data.workspaceId, data.ticketId, request); + if (generation !== routeGeneration) return; + const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES); + if (generation !== routeGeneration) return; + applyTicket(updated); + transitionReason = ""; + } catch (error) { + if (generation === routeGeneration) errorMessage = ticketMutationError(error); + } finally { + if (generation === routeGeneration) busy = null; + } + } + + function closureReasonLabel(reason: TicketClosureReason): string { + return typeof reason === "string" ? relationLabel(reason) : reason.other; } async function appendThread(event: SubmitEvent) { @@ -401,19 +415,6 @@ ) threadBody = ""; } - async function closeTicket(event: SubmitEvent) { - event.preventDefault(); - if (!resolution.trim()) return; - if ( - await mutate("close", "/close", { - resolution: resolution.trim(), - operation_key: crypto.randomUUID(), - expected_content_digest: ticket.content_digest, - expected_state: ticket.state, - }) - ) resolution = ""; - } - function eventTitle(kind: string): string { return relationLabel(kind); } @@ -550,22 +551,29 @@ <aside class="ticket-control-rail"> <section class="ticket-control-card"> - <header><h2>Progress decision</h2></header> - <p class="workspace-empty-copy">State records progress only. It does not start or stop a Worker, grant access, approve review, or merge code. Reopening does not resume an old Worker.</p> - <form class="ticket-control-form" onsubmit={transition}> - <label>State - <select bind:value={nextState}> - {#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each} + <header><h2>Case decision</h2></header> + <p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p> + <form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}> + <label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label> + <label>Closure reason + <select bind:value={closureReason}> + <option value="completed">Completed</option> + <option value="not_planned">Not planned</option> + <option value="duplicate">Duplicate</option> + <option value="unknown">Unknown</option> + <option value="other">Other</option> </select> </label> - <label>Reason<input bind:value={transitionReason} placeholder="Completion, start, or reopening decision" required /></label> - <label>Result, references, and remaining work (optional)<textarea bind:value={progressBody} rows="3" placeholder="A comment can be the result. Add links and remaining questions here."></textarea></label> - <button class="workspace-secondary-button" type="submit" disabled={busy !== null || nextState === ticket.state || !transitionReason.trim()}> - {nextState === "done" ? "Complete Ticket" : ticket.state === "done" || ticket.state === "closed" ? "Reopen / apply state" : "Apply state"} + {#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if} + <button class="workspace-secondary-button" type="submit" disabled={busy !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}> + {ticket.state === "open" ? "Close ticket" : "Reopen ticket"} </button> + {#if ticket.state === "closed" && ticket.current_closure} + <button class="workspace-secondary-button" type="button" disabled={busy !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button> + {/if} </form> - {#if ticket.state === "planning"} - <button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Marking ready…" : "Mark ready"}</button> + {#if ticket.state === "open"} + <button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Recording readiness…" : "Record routing readiness"}</button> {/if} <button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>{busy === "queue" ? "Queueing…" : "Request Orchestrator (queue)"}</button> <p class="workspace-empty-copy">Queue requests only this Ticket. Dependencies remain diagnostic information and are not automatically queued.</p> @@ -631,12 +639,12 @@ </button> </form></details> {/if} - {#if ticket.state === "inprogress" && workerAssignment} + {#if workerAssignment} <details class="ticket-cancel-implementation"> <summary>Cancel implementation</summary> <form class="ticket-control-form" onsubmit={cancelImplementation}> <p class="workspace-empty-copy"> - Cancel the assigned Worker’s unfinished work and return this Ticket to ready. Responsibility is retained. This does not delete the Worker or Workdir. + Cancel the assigned Worker’s unfinished work without changing the case state. Responsibility is retained. This does not delete the Worker or Workdir. </p> <label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label> <button @@ -644,7 +652,7 @@ type="submit" disabled={busy !== null || !cancellationReason.trim()} > - {busy === "cancel-implementation" ? "Cancelling…" : "Cancel and return to ready"} + {busy === "cancel-implementation" ? "Cancelling…" : "Cancel unfinished work"} </button> </form> </details> @@ -724,16 +732,16 @@ {#each ticket.evidence.missing as missing}<li>{missing}</li>{/each} </ul> {/if} - <p class="workspace-empty-copy">This evaluates linked MR requirements, separately from Ticket progress and persisted integration approval.</p> - {#if ticket.state === "done"} - <p class="workspace-empty-copy">This Ticket remains done. Incomplete current requirement evidence does not cancel recorded completion.</p> + <p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p> + {#if ticket.state === "closed"} + <p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p> {/if} </section> {/if} <section class="ticket-control-card"> <header><h2>Merge Requests</h2></header> - <p class="workspace-empty-copy">Ticket completion is not MR approval or integration.</p> + <p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p> {#if mergeRequests.length > 0} {#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)} <article class="ticket-control-card"> @@ -759,20 +767,33 @@ </article> {/each} {:else} - <p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be completed with a comment or document result.</p> + <p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be closed with a comment or document result.</p> {/if} </section> - {#if ticket.state !== "closed"} - <details class="ticket-control-card ticket-close-card"> - <summary>Close ticket</summary> - <form class="ticket-control-form" onsubmit={closeTicket}> - <label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label> - <button class="workspace-danger-button" type="submit" disabled={busy !== null || !resolution.trim()}>Close ticket</button> - </form> + {#if ticket.current_closure} + <section class="ticket-control-card"><header><h2>Current closure</h2></header> + <p>{closureReasonLabel(ticket.current_closure.reason)} · {ticket.current_closure.author} · {prettyDate(ticket.current_closure.at)}</p> + <RichMarkdown text={ticket.current_closure.explanation} /> + {#each ticket.current_closure.references as reference}<p>{reference.kind}: {reference.target}</p>{/each} + </section> + {/if} + {#if ticket.closure_history.length > 0} + <details class="ticket-control-card"><summary>Case history ({ticket.closure_history.length})</summary> + {#each ticket.closure_history as judgment (judgment.operation_key)} + <article> + <p>{relationLabel(judgment.action.action)}{judgment.action.action !== "reopen" ? `: ${closureReasonLabel(judgment.action.reason)}` : ""} · {judgment.author} · {prettyDate(judgment.at)}</p> + <RichMarkdown text={judgment.explanation} /> + {#each judgment.references as reference}<p>{reference.kind}: {reference.target}</p>{/each} + </article> + {/each} + </details> + {/if} + {#if ticket.legacy_handoff} + <details class="ticket-control-card"><summary>Legacy routing handoff</summary> + <p>Historical state: {ticket.legacy_handoff.workflow_state}</p> + {#if ticket.legacy_handoff.resolution}<RichMarkdown text={ticket.legacy_handoff.resolution} />{/if} </details> - {:else if ticket.resolution} - <section class="ticket-control-card"><header><h2>Resolution</h2></header><RichMarkdown text={ticket.resolution} /></section> {/if} </aside> </div> diff --git a/web/workspace/test/ticket-detail-route-reuse.test.ts b/web/workspace/test/ticket-detail-route-reuse.test.ts index cdeab0e06..0c515fb92 100644 --- a/web/workspace/test/ticket-detail-route-reuse.test.ts +++ b/web/workspace/test/ticket-detail-route-reuse.test.ts @@ -30,6 +30,7 @@ Deno.test("ticket detail fences stale mutation responses", () => { const operation of [ "async function mutate(", "async function queueTicket(", + "async function caseDecision(", "async function mutateAssignment(", ] ) { diff --git a/web/workspace/vite.config.ts b/web/workspace/vite.config.ts index 594d31f07..574fddd09 100644 --- a/web/workspace/vite.config.ts +++ b/web/workspace/vite.config.ts @@ -40,6 +40,11 @@ export default defineConfig({ }, proxy: { + // Case judgments use the canonical authenticated Workspace API route. + "/workspaces": { + target: "http://127.0.0.1:8787", + changeOrigin: true, + }, "/api": { target: "http://127.0.0.1:8787", changeOrigin: true,