From da378fc9029123bc54135126c1489aeb7f803eba Mon Sep 17 00:00:00 2001 From: Hare Date: Wed, 14 Oct 2026 14:49:20 +0900 Subject: [PATCH] fix: complete Ticket post integration and expose targets Preserve ordered targets in Workspace ShowTicket and native reads. Complete authenticated post-edit transport, shared dispatch, generated contracts and regression fixtures. Keep canonical content-edit delivery inputs transactional and separate from discussion posts. --- crates/client/src/workspace_product.rs | 57 ++ crates/server-api/src/lib.rs | 12 + crates/ticket/tests/migration.rs | 10 +- .../feature/builtin/resource_projection.rs | 20 +- crates/worker/src/feature/builtin/ticket.rs | 121 ++- .../src/feature/builtin/ticket/tags/tests.rs | 2 +- .../src/assignment_content_inputs.sql | 57 ++ .../src/assignment_delivery.rs | 28 +- .../src/assignment_delivery_tests.rs | 121 ++- crates/workspace-server/src/authority.rs | 95 ++- crates/workspace-server/src/server.rs | 686 +++++++++++++++--- crates/workspace-server/src/store.rs | 56 +- .../src/ticket_post_upgrade_tests.rs | 2 + .../workspace-server/src/workdir_removal.rs | 8 +- .../src/workspace_deletion.rs | 16 +- openapi/server-api.json | 156 +++- .../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 | 2 +- .../src/lib/generated/worker-launch-api.ts | 2 +- .../tickets/ticket-operations.browser.spec.ts | 32 +- 22 files changed, 1311 insertions(+), 178 deletions(-) create mode 100644 crates/workspace-server/src/assignment_content_inputs.sql diff --git a/crates/client/src/workspace_product.rs b/crates/client/src/workspace_product.rs index 25e8895ae..7aafe46e7 100644 --- a/crates/client/src/workspace_product.rs +++ b/crates/client/src/workspace_product.rs @@ -601,6 +601,22 @@ impl BackendWorkspaceProductClient { } impl TicketBackend for BackendWorkspaceProductClient { + fn edit_post_with_snapshots( + &self, + id: TicketIdOrSlug, + edit: ticket::TicketPostEdit, + ) -> ticket::Result<(Ticket, Ticket)> { + let workspace_id = self.workspace_id.clone(); + let id = ticket_reference(&id); + self.generated(move |client| async move { + client + .ticket_record_post_edit(workspace_id, id, server_api::EditTicketPostRequest(edit)) + .await + .map(|response| response.0) + }) + .map_err(ticket_client_error) + } + fn list_workspace_tags( &self, request: ticket::TagListRequest, @@ -994,6 +1010,47 @@ mod tests { (format!("http://{address}"), receiver, handle) } + #[test] + fn post_edit_uses_native_patch_and_returns_server_snapshots_without_reread() { + let temp = tempfile::tempdir().unwrap(); + let backend = + ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a") + .unwrap(); + let mut input = NewTicket::new("Post snapshots"); + input.body = MarkdownText::new("Original requirements"); + let reference = backend.create(input).unwrap(); + let original = backend.show(reference.id.clone().into()).unwrap(); + let edit = ticket::TicketPostEdit { + event_ref: original.events[0].event_ref.clone(), + expected_body: original.document.body.clone(), + body: MarkdownText::new("Changed requirements"), + author: "authenticated-editor".into(), + }; + let expected = backend + .edit_post_with_snapshots(reference.id.into(), edit.clone()) + .unwrap(); + let body = serde_json::to_string(&expected).unwrap(); + let (url, requests, server) = one_response_server("200 OK", &body); + let client = + BackendWorkspaceProductClient::new_with_access_token(url, "workspace-a", "token") + .unwrap(); + let actual = TicketBackend::edit_post_with_snapshots( + &client, + TicketIdOrSlug::Query("T-123".into()), + edit.clone(), + ) + .unwrap(); + assert_eq!(actual, expected); + let request = requests.recv().unwrap(); + assert!(request.starts_with("PATCH /api/w/workspace-a/tickets/T-123/posts HTTP/1.1\r\n")); + let (_, body) = request.split_once("\r\n\r\n").unwrap(); + assert_eq!( + serde_json::from_str::(body).unwrap(), + edit + ); + server.join().unwrap(); + } + #[test] fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); diff --git a/crates/server-api/src/lib.rs b/crates/server-api/src/lib.rs index 16f5b575a..0d9f2c715 100644 --- a/crates/server-api/src/lib.rs +++ b/crates/server-api/src/lib.rs @@ -312,6 +312,8 @@ impl_openapi_schema!( CreateTicketRelationRequest, DefaultIntakeReadyBodyRequest, EditTicketRecordItemRequest, + EditTicketPostRequest, + TicketPostSnapshots, MergeEvent, MergeRequestDetailResponse, MergeRequestListQuery, @@ -3085,6 +3087,14 @@ pub trait ServerApi { #[path] id: String, #[body] request: EditTicketRecordItemRequest, ) -> Result; + #[patch("/api/w/{workspace_id}/tickets/{id}/posts", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)] + async fn ticket_record_post_edit( + &self, + #[extension] context: ServerRequestContext, + #[path] workspace_id: String, + #[path] id: String, + #[body] request: EditTicketPostRequest, + ) -> Result; #[get("/api/w/{workspace_id}/tickets/{id}/dependency-check", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)] async fn ticket_dependency_check( &self, @@ -8072,6 +8082,8 @@ transparent_ticket_dto!( ); transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket); transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit); +transparent_ticket_dto!(EditTicketPostRequest, ticket::TicketPostEdit); +transparent_ticket_dto!(TicketPostSnapshots, (ticket::Ticket, ticket::Ticket)); transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent); transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary); transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation); diff --git a/crates/ticket/tests/migration.rs b/crates/ticket/tests/migration.rs index 5c8157c5d..ecf993a9d 100644 --- a/crates/ticket/tests/migration.rs +++ b/crates/ticket/tests/migration.rs @@ -599,14 +599,8 @@ fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_recei reopened.events[0] .attributes .retain(|key, _| matches!(key.as_str(), "event_id" | "event_sequence")); - // The frozen v9 copy has no persisted event_id attributes. Its published - // fallback was a zero-based ordinal, not the source v12 persisted identity. - for (ordinal, event) in reopened.events.iter_mut().skip(1).enumerate() { - event.event_ref = format!("sequence:{ordinal}"); - event - .attributes - .insert("event_id".into(), event.event_ref.clone()); - } + // The frozen SQLite loader published ticket_id:event_index identities. + // These remain identical after migration, without replacing them with display ordinals. let path = temp.path().join("retained9.db"); let connection = Connection::open(&path).unwrap(); schema7(&connection); diff --git a/crates/worker/src/feature/builtin/resource_projection.rs b/crates/worker/src/feature/builtin/resource_projection.rs index 09966f26b..a9b7049f8 100644 --- a/crates/worker/src/feature/builtin/resource_projection.rs +++ b/crates/worker/src/feature/builtin/resource_projection.rs @@ -37,6 +37,14 @@ struct ModelTicketQueryItem { merge_request: Option, } +#[derive(Debug, serde::Deserialize, Serialize)] +struct ModelTicketTarget { + repository_key: String, + // Keep an unspecified selector explicit in the model-facing read contract. + ref_selector: Option, + access: ticket::TicketTargetAccess, +} + #[derive(Debug, Serialize)] pub(super) struct ModelTicketDetail { ticket: String, @@ -45,6 +53,7 @@ pub(super) struct ModelTicketDetail { body: String, body_truncated: bool, initial_post: ModelTicketEvent, + targets: Vec, created_by: Option, state: ticket::TicketState, closure_operation_key: Option, @@ -351,6 +360,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result { - "Show one authoritative Workspace Ticket with its content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence." + "Show one authoritative Workspace Ticket with its ordered implementation targets (repository_key, ref_selector, access), content digest, paged thread, links, historical implementation reports, and current Merge Request readiness evidence. Read targets here before choosing a work repository; a null selector remains unspecified, not an inferred default." } } } @@ -2529,8 +2529,8 @@ mod tests { ); if status == 200 { let (previous, current) = result.unwrap(); - assert_eq!(previous.meta.id, record.meta.id); - assert_eq!(current.meta.id, record.meta.id); + assert_eq!(previous.meta.id, "T-718"); + assert_eq!(current.meta.id, "T-718"); } else { assert!(result.unwrap_err().to_string().contains("409")); } @@ -2545,6 +2545,121 @@ mod tests { } } + fn target_detail_fixture(targets: Value) -> Value { + json!({ + "resource_key": "T-739", "title": "Work location", "body": "Requirements", + "body_truncated": false, "content_digest": "observed-content", "targets": targets, + "initial_post": { + "sequence": 0, "event_ref": "initial", "is_initial": true, + "kind": "comment", "body": "Requirements", "body_truncated": false, + "body_digest": "a".repeat(64), "editable": true, + "author": null, "at": null, "edited_at": null, "edited_by": null + }, + "state": "open", "closure_operation_key": null, "current_closure": null, + "legacy_handoff": null, "closure_history": [], "assignment_tracking": [], + "tags": [], "events": [], "linked_objectives": [], "assignments": [], + "implementation_reports": [], "merge_requests": [] + }) + } + + #[tokio::test] + async fn orchestrator_show_and_native_read_preserve_ordered_targets() { + for targets in [ + json!([]), + json!([ + {"repository_key": "main", "ref_selector": "develop", "access": "read_write"}, + {"repository_key": "docs", "ref_selector": null, "access": "read_only"}, + {"repository_key": "archive", "ref_selector": "refs/tags/v1", "access": "read_only"} + ]), + ] { + let client = Arc::new(DecisionClient { + requests: Mutex::new(Vec::new()), + response: crate::worker::WorkspaceResponse { + status: 200, + body: target_detail_fixture(targets.clone()).to_string(), + }, + }); + let definitions = + enabled_ticket_definitions(client.clone(), TicketFeatureAccess::workflow()); + let definition = definitions + .into_iter() + .find(|definition| definition().0.name == "ShowTicket") + .unwrap(); + let (meta, tool) = definition(); + assert!(meta.description.contains("targets")); + let output = tool + .execute( + r#"{"id":"T-739","event_limit":1}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + assert_eq!( + ticket_tool_output_json(output) + .unwrap_or_else(|_| panic!("Ticket read returned invalid JSON"))["targets"], + targets + ); + + let native = + native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap(); + let read = native + .into_iter() + .find(|tool| tool.projection.operation == "read") + .unwrap(); + let output = read + .tool + .execute( + r#"{"id":"T-739","event_cursor":"10"}"#, + ToolExecutionContext::direct(), + ) + .await + .unwrap(); + assert_eq!( + ticket_tool_output_json(output) + .unwrap_or_else(|_| panic!("Ticket read returned invalid JSON"))["targets"], + targets + ); + let requests = client.requests.lock().unwrap(); + assert_eq!(requests.len(), 2); + assert!( + requests + .iter() + .all(|request| request.path == "/api/w/workspace/tickets/T-739/show") + ); + } + } + + #[tokio::test] + async fn show_rejects_missing_or_malformed_targets_instead_of_reporting_unassigned() { + for malformed in [ + None, + Some(Value::Null), + Some(json!({})), + Some(json!([{"repository_key":"main","access":"write"}])), + ] { + let mut detail = target_detail_fixture(json!([])); + if let Some(value) = malformed { + detail["targets"] = value; + } else { + detail.as_object_mut().unwrap().remove("targets"); + } + let client = Arc::new(DecisionClient { + requests: Mutex::new(Vec::new()), + response: crate::worker::WorkspaceResponse { + status: 200, + body: detail.to_string(), + }, + }); + let (_, tool) = + workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)(); + let error = tool + .execute(r#"{"id":"T-739"}"#, ToolExecutionContext::direct()) + .await + .unwrap_err(); + assert!(error.to_string().contains("targets"), "{error}"); + } + } + #[test] fn query_schema_allows_only_open_and_closed_case_states() { let client = decision_client(200, TicketState::Open); diff --git a/crates/worker/src/feature/builtin/ticket/tags/tests.rs b/crates/worker/src/feature/builtin/ticket/tags/tests.rs index b1044b0e0..893f2dd01 100644 --- a/crates/worker/src/feature/builtin/ticket/tags/tests.rs +++ b/crates/worker/src/feature/builtin/ticket/tags/tests.rs @@ -139,7 +139,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations() assert_eq!(body[field], input[field]); } } - let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]}); + let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "body_truncated":false, "targets":[], "initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Body","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null}, "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]}); let client = recording(detail); let (_, tool) = workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)(); diff --git a/crates/workspace-server/src/assignment_content_inputs.sql b/crates/workspace-server/src/assignment_content_inputs.sql new file mode 100644 index 000000000..850063e9d --- /dev/null +++ b/crates/workspace-server/src/assignment_content_inputs.sql @@ -0,0 +1,57 @@ +-- Schema 94 operational inputs: canonical edits no longer append Ticket events. +-- Record input identity in the content transaction, never reconstruct it from +-- mutable text/timestamps or turn an old migrated edit into a new delegation. +CREATE TABLE ticket_assignment_content_inputs ( + input_sequence INTEGER PRIMARY KEY AUTOINCREMENT, + workspace_id TEXT NOT NULL, + ticket_id TEXT NOT NULL, + assignment_id TEXT NOT NULL, + after_ticket_event_index INTEGER NOT NULL, + FOREIGN KEY(workspace_id,ticket_id,assignment_id) + REFERENCES ticket_worker_assignments(workspace_id,ticket_id,assignment_id) +); +CREATE TRIGGER ticket_assignment_content_input_no_update +BEFORE UPDATE ON ticket_assignment_content_inputs +BEGIN SELECT RAISE(ABORT,'Assignment content input is immutable'); END; +CREATE TRIGGER ticket_assignment_content_input_no_delete +BEFORE DELETE ON ticket_assignment_content_inputs +BEGIN SELECT RAISE(ABORT,'Assignment content input is immutable'); END; +CREATE TRIGGER ticket_assignment_title_input +AFTER UPDATE OF title ON typed_tickets +WHEN NEW.title IS NOT OLD.title +BEGIN + INSERT INTO ticket_assignment_content_inputs + (workspace_id,ticket_id,assignment_id,after_ticket_event_index) + SELECT a.workspace_id,a.ticket_id,a.assignment_id, + COALESCE((SELECT MAX(event_index) FROM typed_ticket_events + WHERE workspace_id=a.workspace_id AND ticket_id=a.ticket_id),-1) + FROM ticket_current_worker_assignments c + JOIN ticket_worker_assignments a USING(workspace_id,ticket_id,assignment_id) + JOIN ticket_assignment_delivery_origins o USING(workspace_id,ticket_id,assignment_id) + WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id + AND a.role='orchestrator' AND a.principal_kind='workspace_agent' + AND a.principal_id='workspace-orchestrator' AND NEW.workflow_state='open' + AND json_extract(a.tracking_json,'$.status') IN ('waiting','inprogress') + AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r + WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id); +END; +CREATE TRIGGER ticket_assignment_initial_post_input +AFTER UPDATE OF body ON typed_ticket_events +WHEN NEW.event_index=-1 AND NEW.body IS NOT OLD.body +BEGIN + INSERT INTO ticket_assignment_content_inputs + (workspace_id,ticket_id,assignment_id,after_ticket_event_index) + SELECT a.workspace_id,a.ticket_id,a.assignment_id, + COALESCE((SELECT MAX(event_index) FROM typed_ticket_events + WHERE workspace_id=a.workspace_id AND ticket_id=a.ticket_id),-1) + FROM ticket_current_worker_assignments c + JOIN ticket_worker_assignments a USING(workspace_id,ticket_id,assignment_id) + JOIN ticket_assignment_delivery_origins o USING(workspace_id,ticket_id,assignment_id) + JOIN typed_tickets t USING(workspace_id,ticket_id) + WHERE a.workspace_id=NEW.workspace_id AND a.ticket_id=NEW.ticket_id + AND a.role='orchestrator' AND a.principal_kind='workspace_agent' + AND a.principal_id='workspace-orchestrator' AND t.workflow_state='open' + AND json_extract(a.tracking_json,'$.status') IN ('waiting','inprogress') + AND NOT EXISTS(SELECT 1 FROM ticket_assignment_work_releases r + WHERE r.workspace_id=a.workspace_id AND r.assignment_id=a.assignment_id); +END; diff --git a/crates/workspace-server/src/assignment_delivery.rs b/crates/workspace-server/src/assignment_delivery.rs index 73c9197b9..efee9e1cd 100644 --- a/crates/workspace-server/src/assignment_delivery.rs +++ b/crates/workspace-server/src/assignment_delivery.rs @@ -126,6 +126,12 @@ WHERE json_extract(h.source_context_json,'$.kind')='migration' Ok(()) } +/// Additive schema94 composition; keep the applied schema93 install unchanged. +pub(super) fn install_post_inputs(conn: &Connection) -> Result<()> { + conn.execute_batch(include_str!("assignment_content_inputs.sql"))?; + Ok(()) +} + const INPUTS_SQL: &str = r#" WITH eligible AS ( SELECT a.*,o.input_event_id,o.after_ticket_event_index @@ -152,6 +158,10 @@ WITH eligible AS ( AND e.kind NOT LIKE 'assignment_delivery%' AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking') AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>a.assignment_id) + UNION ALL + SELECT a.ticket_id,a.assignment_id,'ticket-edit:' || printf('%020d',e.input_sequence),a.tracking_json,e.after_ticket_event_index+1 + FROM eligible a JOIN ticket_assignment_content_inputs e + ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id AND e.assignment_id=a.assignment_id ) SELECT i.ticket_id,i.assignment_id,i.input_event_id, json_extract(i.tracking_json,'$.waiting_reason'),json_extract(i.tracking_json,'$.next_action'),d.payload_json @@ -180,7 +190,9 @@ fn inputs_in_tx( assignment_id: Option<&str>, input_event_id: Option<&str>, ) -> Result> { - let sql = format!("{INPUTS_SQL} ORDER BY i.ticket_id,i.assignment_id,i.input_order LIMIT ?2"); + let sql = format!( + "{INPUTS_SQL} ORDER BY i.ticket_id,i.assignment_id,i.input_order,i.input_event_id LIMIT ?2" + ); let mut statement = conn.prepare(&sql)?; let rows = statement.query_map( params![workspace_id, limit as i64, assignment_id, input_event_id], @@ -404,12 +416,15 @@ pub(super) fn save_operation_fence( // Same input selection as delivery, plus immutable delivered history. Never // SELECT payload/source/content into the public diagnostic projection. const DIAGNOSTIC_INPUTS_SQL: &str = r#" -WITH inputs AS ( +WITH raw_inputs AS ( SELECT o.input_event_id,-1 AS input_order FROM ticket_assignment_delivery_origins o WHERE o.workspace_id=?1 AND o.assignment_id=?3 UNION - SELECT d.input_event_id,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END - FROM ticket_assignment_deliveries d WHERE d.workspace_id=?1 AND d.assignment_id=?3 + SELECT d.input_event_id,COALESCE(e.after_ticket_event_index+1,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END) + FROM ticket_assignment_deliveries d + LEFT JOIN ticket_assignment_content_inputs e ON e.workspace_id=d.workspace_id AND e.assignment_id=d.assignment_id + AND d.input_event_id='ticket-edit:' || printf('%020d',e.input_sequence) + WHERE d.workspace_id=?1 AND d.assignment_id=?3 UNION SELECT 'ticket-event:' || e.event_index,e.event_index FROM ticket_assignment_delivery_origins o JOIN typed_ticket_events e ON e.workspace_id=o.workspace_id AND e.ticket_id=o.ticket_id @@ -425,6 +440,11 @@ WITH inputs AS ( JOIN ticket_worker_assignments original ON original.workspace_id=o.workspace_id AND original.assignment_id=o.assignment_id WHERE later.workspace_id=o.workspace_id AND later.ticket_id=o.ticket_id AND la.rowid>original.rowid),9223372036854775807) AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>?3) + UNION + SELECT 'ticket-edit:' || printf('%020d',input_sequence),after_ticket_event_index+1 + FROM ticket_assignment_content_inputs WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3 +), inputs AS ( + SELECT input_event_id,ROW_NUMBER() OVER (ORDER BY input_order,input_event_id) AS input_order FROM raw_inputs ) "#; diff --git a/crates/workspace-server/src/assignment_delivery_tests.rs b/crates/workspace-server/src/assignment_delivery_tests.rs index c8e2cb7f6..ae8ff43cd 100644 --- a/crates/workspace-server/src/assignment_delivery_tests.rs +++ b/crates/workspace-server/src/assignment_delivery_tests.rs @@ -6,7 +6,7 @@ fn delivery_store_external_event( state_field: Option<&str>, ) { store.with_conn(|conn| { - conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,state_field,body) VALUES('w','tracking',?1,?2,'user','2026-10-12T00:00:00Z',?3,'Answer arrived')",params![index,kind,state_field])?; + conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,author,at,state_field,body) VALUES('w','tracking',?1,'tracking:' || ?1,?2,'user','2026-10-12T00:00:00Z',?3,'Answer arrived')",params![index,kind,state_field])?; Ok(()) }).unwrap(); } @@ -651,7 +651,7 @@ fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_l ); store .with_conn(|conn| { - assert_eq!(current_schema_version(conn)?, 93); + assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION); assert_eq!( stored_fixture_rows(conn, "ticket_worker_assignments"), assignments @@ -1033,3 +1033,120 @@ async fn assignment_diagnostic_start_cursor_pages_existing_claims_without_input_ let wire = serde_json::to_string(&first).unwrap(); assert!(!wire.contains("tracking-binding") && !wire.contains("External resource")); } + +#[tokio::test] +async fn assignment_content_edits_are_atomic_durable_inputs_without_appending_posts() { + use ticket::TicketBackend; + let (directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let initial = delivery_store_reserve_initial(&store, &source); + store.ack_ticket_assignment_delivery("w", &initial.delivery_id, &source).unwrap(); + let path = directory.path().join("workspace.db"); + let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap(); + let original = backend.show("tracking".into()).unwrap(); + backend.edit_item("tracking".into(), ticket::TicketItemEdit { + title: Some("Changed title".into()), ..Default::default() + }).unwrap(); + backend.edit_item("tracking".into(), ticket::TicketItemEdit { + title: Some("Changed title".into()), ..Default::default() + }).unwrap(); + let mut latest = backend.show("tracking".into()).unwrap(); + for body in ["Changed requirements", "Final requirements"] { + backend.edit_post("tracking".into(), ticket::TicketPostEdit { + event_ref: latest.events[0].event_ref.clone(), + expected_body: latest.document.body.clone(), + body: ticket::MarkdownText::new(body), author: "fixture-editor".into(), + }).unwrap(); + latest = backend.show("tracking".into()).unwrap(); + } + assert_eq!(latest.events.len(), original.events.len()); + assert_eq!(latest.events[0].event_ref, original.events[0].event_ref); + backend.add_event("tracking".into(), ticket::NewTicketEvent::new( + ticket::TicketEventKind::Comment, "Discussion", + )).unwrap(); + let comment = backend.show("tracking".into()).unwrap().events.last().unwrap().clone(); + backend.edit_post("tracking".into(), ticket::TicketPostEdit { + event_ref: comment.event_ref, expected_body: comment.body, + body: ticket::MarkdownText::new("Edited discussion"), author: "fixture-editor".into(), + }).unwrap(); + // Input insertion participates in the content write, including rollback. + store.with_conn_mut(|conn| { + let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?; + tx.execute("UPDATE typed_tickets SET title='Rolled back' WHERE ticket_id='tracking'", [])?; + tx.rollback()?; + assert!(conn.execute("UPDATE ticket_assignment_content_inputs SET assignment_id='other'", []).is_err()); + assert!(conn.execute("DELETE FROM ticket_assignment_content_inputs", []).is_err()); + Ok(()) + }).unwrap(); + let pending = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap(); + assert_eq!(pending.len(), 3, "no-op and discussion edits are not new request inputs"); + assert!(pending.iter().all(|input| input.input_event_id.starts_with("ticket-edit:"))); + let mut cursor = None; + let mut diagnostics = Vec::new(); + loop { + let page = store.page_ticket_assignment_diagnostics("w", "tracking", "a", cursor.as_deref(), None, 1).unwrap(); + diagnostics.extend(page.inputs.into_iter().map(|input| input.input_event_id)); + cursor = page.next_input_event_id; + if cursor.is_none() { break; } + } + assert_eq!(diagnostics, std::iter::once(initial.input_event_id.clone()) + .chain(pending.iter().map(|input| input.input_event_id.clone())).collect::>()); + drop(store); + let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap(); + assert_eq!(reopened.list_pending_ticket_assignment_deliveries("w", 100).unwrap(), pending); + for input in pending { + let reserved = reopened.reserve_ticket_assignment_delivery("w", "a", &input.input_event_id, &source).unwrap(); + assert_eq!(reserved, input); + reopened.ack_ticket_assignment_delivery("w", &reserved.delivery_id, &source).unwrap(); + } + assert!(reopened.list_pending_ticket_assignment_deliveries("w", 100).unwrap().is_empty()); +} + +#[tokio::test] +async fn assignment_content_edits_never_revive_done_closed_or_reissued_delegations() { + for change in ["done", "close-reopen", "reissue"] { + let (directory, store) = tracking_store_fixture().await; + tracking_store_assign(&store, "a", "assign-a"); + let source = tracking_store_source(); + let initial = delivery_store_reserve_initial(&store, &source); + store.ack_ticket_assignment_delivery("w", &initial.delivery_id, &source).unwrap(); + store.with_conn(|conn| { + conn.execute("UPDATE typed_tickets SET title='Input before authority change' WHERE ticket_id='tracking'", [])?; + Ok(()) + }).unwrap(); + let old = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap().remove(0); + match change { + "done" => { store.update_ticket_assignment_tracking("w", "tracking", &tracking_store_input("a", "event-assign-a", "done-a"), &source).unwrap(); } + "close-reopen" => { store.with_conn(|conn| { conn.execute_batch("UPDATE typed_tickets SET workflow_state='closed' WHERE ticket_id='tracking'; UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id='tracking';")?; Ok(()) }).unwrap(); } + _ => { tracking_store_assign(&store, "b", "assign-b"); } + } + use ticket::TicketBackend; + let backend = ticket::SqliteTicketBackend::open_verified(directory.path().join("workspace.db"), "w").unwrap(); + backend.edit_item("tracking".into(), ticket::TicketItemEdit { + body: Some(ticket::MarkdownText::new("New requirements")), author: Some("fixture-editor".into()), ..Default::default() + }).unwrap(); + let pending = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap(); + if change == "reissue" { + assert_eq!(pending.len(), 2, "new delegation and its content edit only"); + assert!(pending.iter().all(|input| input.assignment_id == "b")); + } else { assert!(pending.is_empty(), "{change}"); } + assert!(store.reserve_ticket_assignment_delivery("w", "a", &old.input_event_id, &source).is_err()); + let page = store.page_ticket_assignment_diagnostics("w", "tracking", "a", None, None, 100).unwrap(); + assert!(!page.actionable); + assert_eq!(page.inputs.len(), 2, "retain old input evidence without creating new authority"); + } +} + +#[tokio::test] +async fn assignment_content_input_missing_guards_refuse_startup() { + for trigger in ["ticket_assignment_title_input", "ticket_assignment_initial_post_input", "ticket_assignment_content_input_no_update", "ticket_assignment_content_input_no_delete"] { + let (directory, store) = tracking_store_fixture().await; + store.with_conn(|conn| { conn.execute_batch(&format!("DROP TRIGGER {trigger}"))?; Ok(()) }).unwrap(); + drop(store); + let error = match SqliteWorkspaceStore::open_for_test(directory.path().join("workspace.db")) { + Ok(_) => panic!("missing {trigger} was accepted"), Err(error) => error, + }; + assert!(error.to_string().contains(trigger), "{error}"); + } +} diff --git a/crates/workspace-server/src/authority.rs b/crates/workspace-server/src/authority.rs index 395cb78d4..fad400daa 100644 --- a/crates/workspace-server/src/authority.rs +++ b/crates/workspace-server/src/authority.rs @@ -762,12 +762,19 @@ impl SqliteWorkspaceAuthority { let id = ticket.meta.id.as_str(); let dependency_check = dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?; - let initial = ticket.events.iter().find(|event| event.is_initial) + let initial = ticket + .events + .iter() + .find(|event| event.is_initial) .ok_or_else(|| Error::Store("Ticket initial post is missing".into()))?; let initial_post = ticket_event_detail(0, initial); let body = initial_post.body.clone().unwrap_or_default(); let body_truncated = initial_post.body_truncated; - let subsequent_events = ticket.events.iter().filter(|event| !event.is_initial).collect::>(); + let subsequent_events = ticket + .events + .iter() + .filter(|event| !event.is_initial) + .collect::>(); let event_limit = request .event_limit .unwrap_or(TICKET_EVENT_LIMIT) @@ -1787,11 +1794,21 @@ fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String { pub(crate) fn ticket_post_body_digest(body: &str) -> String { use sha2::{Digest, Sha256}; - format!("{:x}", Sha256::digest(body.as_bytes())) + Sha256::digest(body.as_bytes()) + .iter() + .map(|byte| format!("{byte:02x}")) + .collect() } pub(crate) fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail { - let (body, body_truncated) = truncate_body(event.body.as_str(), if event.is_initial { DETAIL_BODY_LIMIT } else { TICKET_EVENT_BODY_LIMIT }); + let (body, body_truncated) = truncate_body( + event.body.as_str(), + if event.is_initial { + DETAIL_BODY_LIMIT + } else { + TICKET_EVENT_BODY_LIMIT + }, + ); TicketEventDetail { is_initial: event.is_initial, edited_at: event.edited_at.clone(), @@ -1946,6 +1963,11 @@ pub(crate) fn merge_request_summary( } fn substantive_item_edit(event: &TicketEvent) -> bool { + // Initial-comment edits replace requirements without appending an item_edit event. + // Discussion-comment edits do not change the Ticket's requirement scope. + if event.is_initial && event.edited_at.is_some() { + return true; + } if event.kind.as_str() != "item_edit" { return false; } @@ -3041,6 +3063,10 @@ mod tests { attributes.insert("changes".to_string(), changes.to_string()); } TicketEvent { + event_ref: format!("event:{kind}:{at}"), + is_initial: false, + edited_at: None, + edited_by: None, kind: ticket::TicketEventKind::Other(kind.to_string()), author: Some("coder".to_string()), at: Some(at.to_string()), @@ -3056,6 +3082,40 @@ mod tests { } } + #[test] + fn edited_initial_post_marks_rescope_but_discussion_edits_do_not() { + let evidence = TicketEvidenceSummary { + missing: vec!["review_after_rescope".into()], + ..Default::default() + }; + let mut post = ticket_event("comment", "2026-10-13T00:00:00Z", None); + post.kind = ticket::TicketEventKind::Comment; + post.is_initial = true; + assert!(!ticket_attention_matches( + "open", + &evidence, + false, + &[post.clone()], + "stale_after_rescope" + )); + post.edited_at = Some("2026-10-13T01:00:00Z".into()); + assert!(ticket_attention_matches( + "open", + &evidence, + false, + &[post.clone()], + "stale_after_rescope" + )); + post.is_initial = false; + assert!(!ticket_attention_matches( + "open", + &evidence, + false, + &[post], + "stale_after_rescope" + )); + } + fn unavailable_source( code: &str, ) -> std::result::Result { @@ -3615,30 +3675,29 @@ mod tests { .execute_batch( r#" INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, + workspace_id, ticket_id, slug, title, status, kind, priority, created_at, updated_at, workflow_state, workflow_state_explicit ) VALUES ('workspace-test', '00000000001J2', '00000000001J2', 'Read bridge', 'open', 'ticket', 'P2', - 'Ticket body. Deep Ticket marker. This body is intentionally long enough for bounded projection tests.', '2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'open', 1), ('workspace-test', '00000000001J5', '00000000001J5', 'Second ticket', 'open', 'ticket', 'P2', - 'Ticket body', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'open', 1), + '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'open', 1), ('workspace-test', '00000000001J6', '00000000001J6', 'Third ticket', 'open', 'ticket', 'P2', - 'Ticket body', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'open', 1); + '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'open', 1); INSERT INTO typed_ticket_events ( - workspace_id, ticket_id, event_index, kind, author, at, heading, body + workspace_id, ticket_id, event_index, event_ref, kind, author, at, body ) VALUES - ('workspace-test', '00000000001J2', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'), - ('workspace-test', '00000000001J5', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'), - ('workspace-test', '00000000001J6', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'); + ('workspace-test', '00000000001J2', -1, '00000000001J2:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body'), + ('workspace-test', '00000000001J5', -1, '00000000001J5:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body'), + ('workspace-test', '00000000001J6', -1, '00000000001J6:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body'); "#, ) .unwrap(); rusqlite::Connection::open(&db_path) .unwrap() .execute( - "UPDATE typed_tickets SET body = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2", + "UPDATE typed_ticket_events SET body = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1", rusqlite::params![ "workspace-test", "00000000001J2", @@ -4044,11 +4103,15 @@ VALUES ('workspace-test', 'ticket', 4); .execute_batch( r#" INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, + workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit ) VALUES - ('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1), - ('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1); + ('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', 'open', 1), + ('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', 'open', 1); +INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, event_ref, kind, body) +VALUES + ('workspace-test', '00000000001J2', -1, '00000000001J2:-1', 'comment', ''), + ('workspace-test', '00000000001J3', -1, '00000000001J3:-1', 'comment', ''); INSERT INTO workspace_resource_keys ( workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at ) VALUES diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 145a224c9..4f939d7f8 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -6948,6 +6948,15 @@ fn generated_workspace_contract_router(service: ServerApiContractService) -> Rou .merge(server_api::server_api_axum::ticket_orchestration_plan_record(service.clone())) .merge(server_api::server_api_axum::ticket_get(service.clone())) .merge(server_api::server_api_axum::ticket_edit(service.clone())) + .merge(server_api::server_api_axum::ticket_post_get( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_post_edit( + service.clone(), + )) + .merge(server_api::server_api_axum::ticket_record_post_edit( + service.clone(), + )) .merge(server_api::server_api_axum::ticket_show(service.clone())) .merge(server_api::server_api_axum::ticket_assignment_list( service.clone(), @@ -10286,12 +10295,19 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::CreateTicketRecordRequest, ) -> std::result::Result { let result = execute_ticket_rest_operation_with_context( - self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context), + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + Some(&context), TicketBackendOperation::Create { input: request.0 }, - ).await.map_err(ApiError::into_repository_api_error)?; + ) + .await + .map_err(ApiError::into_repository_api_error)?; let Json(response) = ticket_rest_result(result, |result| match result { - TicketBackendOperationResult::TicketRef(ticket) => Some(ticket), _ => None, - }).map_err(ApiError::into_repository_api_error)?; + TicketBackendOperationResult::TicketRef(ticket) => Some(ticket), + _ => None, + }) + .map_err(ApiError::into_repository_api_error)?; Ok(server_api::TicketRecordRef(response)) } @@ -10432,15 +10448,54 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::EditTicketRecordItemRequest, ) -> std::result::Result { let result = execute_ticket_rest_operation_with_context( - self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context), - TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(id), edit: request.0 }, - ).await.map_err(ApiError::into_repository_api_error)?; + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + Some(&context), + TicketBackendOperation::EditItem { + id: TicketIdOrSlug::Query(id), + edit: request.0, + }, + ) + .await + .map_err(ApiError::into_repository_api_error)?; let Json(response) = ticket_rest_result(result, |result| match result { - TicketBackendOperationResult::Ticket(ticket) => Some(ticket), _ => None, - }).map_err(ApiError::into_repository_api_error)?; + TicketBackendOperationResult::Ticket(ticket) => Some(ticket), + _ => None, + }) + .map_err(ApiError::into_repository_api_error)?; Ok(server_api::TicketRecord(response)) } + async fn ticket_record_post_edit( + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + request: server_api::EditTicketPostRequest, + ) -> std::result::Result { + let result = execute_ticket_rest_operation_with_context( + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + Some(&context), + TicketBackendOperation::EditPost { + id: TicketIdOrSlug::Query(id), + edit: request.0, + }, + ) + .await + .map_err(ApiError::into_repository_api_error)?; + let Json(response) = ticket_rest_result(result, |result| match result { + TicketBackendOperationResult::TicketSnapshots { previous, current } => { + Some((previous, current)) + } + _ => None, + }) + .map_err(ApiError::into_repository_api_error)?; + Ok(server_api::TicketPostSnapshots(response)) + } + async fn ticket_dependency_check( &self, context: server_api::ServerRequestContext, @@ -10468,9 +10523,17 @@ impl server_api::ServerApi for ServerApiContractService { request: server_api::TicketThreadEventRequest, ) -> std::result::Result<(), server_api::RepositoryApiError> { let result = execute_ticket_rest_operation_with_context( - self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context), - TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(id), event: request.0 }, - ).await.map_err(ApiError::into_repository_api_error)?; + self.workspace_api()?, + &workspace_id, + contract_request_headers(&context)?, + Some(&context), + TicketBackendOperation::AddEvent { + id: TicketIdOrSlug::Query(id), + event: request.0, + }, + ) + .await + .map_err(ApiError::into_repository_api_error)?; ticket_rest_unit(result).map_err(ApiError::into_repository_api_error)?; Ok(()) } @@ -10948,18 +11011,34 @@ impl server_api::ServerApi for ServerApiContractService { } async fn ticket_post_get( - &self, workspace_id: String, id: String, event_ref: String, + &self, + workspace_id: String, + id: String, + event_ref: String, ) -> std::result::Result { scoped_get_ticket_post(self.workspace_api()?.clone(), workspace_id, id, event_ref) - .await.map_err(ApiError::into_repository_api_error) + .await + .map_err(ApiError::into_repository_api_error) } async fn ticket_post_edit( - &self, context: server_api::ServerRequestContext, workspace_id: String, id: String, - event_ref: String, request: server_api::TicketPostEditRequest, + &self, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + event_ref: String, + request: server_api::TicketPostEditRequest, ) -> std::result::Result { - let Json(detail) = scoped_edit_ticket_post(self.workspace_api()?.clone(), context, - workspace_id, id, event_ref, request).await.map_err(ApiError::into_repository_api_error)?; + let Json(detail) = scoped_edit_ticket_post( + self.workspace_api()?.clone(), + context, + workspace_id, + id, + event_ref, + request, + ) + .await + .map_err(ApiError::into_repository_api_error)?; Ok(detail) } @@ -15435,24 +15514,52 @@ async fn scoped_edit_ticket_item( ) -> ApiResult> { let targets = request.target.map(|target| match target { server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set { - targets: targets.into_iter().map(|target| ticket::TicketTarget { - repository_key: target.repository_key, ref_selector: target.ref_selector, access: target.access, - }).collect(), + targets: targets + .into_iter() + .map(|target| ticket::TicketTarget { + repository_key: target.repository_key, + ref_selector: target.ref_selector, + access: target.access, + }) + .collect(), }, server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear, }); let body_replacement = match (request.old_string, request.new_string) { - (Some(old_string), Some(new_string)) => Some(TicketBodyReplacement { old_string, new_string, replace_all: request.replace_all }), + (Some(old_string), Some(new_string)) => Some(TicketBodyReplacement { + old_string, + new_string, + replace_all: request.replace_all, + }), (None, None) => None, - _ => return Err(settings_bad_request("invalid_ticket_edit_replacement", "old_string and new_string must be provided together")), + _ => { + return Err(settings_bad_request( + "invalid_ticket_edit_replacement", + "old_string and new_string must be provided together", + )); + } }; - execute_ticket_rest_operation_with_context(&api, &path.workspace_id, - contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context), - TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(path.id.clone()), edit: TicketItemEdit { - title: request.title, body: request.body.map(MarkdownText::new), body_replacement, - targets, author: request.author, - } }).await?; - api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await + execute_ticket_rest_operation_with_context( + &api, + &path.workspace_id, + contract_request_headers(context).map_err(|error| Error::Config(error.message))?, + Some(context), + TicketBackendOperation::EditItem { + id: TicketIdOrSlug::Query(path.id.clone()), + edit: TicketItemEdit { + title: request.title, + body: request.body.map(MarkdownText::new), + body_replacement, + targets, + author: request.author, + }, + }, + ) + .await?; + api.host_executors + .host()? + .run(move || browser_ticket_detail(&api, &path.id)) + .await } async fn scoped_append_ticket_event( @@ -15465,14 +15572,27 @@ async fn scoped_append_ticket_event( server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment, server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan, server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision, - server_api::BrowserTicketThreadRole::ImplementationReport => TicketEventKind::ImplementationReport, + server_api::BrowserTicketThreadRole::ImplementationReport => { + TicketEventKind::ImplementationReport + } }; let mut event = NewTicketEvent::new(role, request.body); event.author = request.author; - execute_ticket_rest_operation_with_context(&api, &path.workspace_id, - contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context), - TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(path.id.clone()), event }).await?; - api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await + execute_ticket_rest_operation_with_context( + &api, + &path.workspace_id, + contract_request_headers(context).map_err(|error| Error::Config(error.message))?, + Some(context), + TicketBackendOperation::AddEvent { + id: TicketIdOrSlug::Query(path.id.clone()), + event, + }, + ) + .await?; + api.host_executors + .host()? + .run(move || browser_ticket_detail(&api, &path.id)) + .await } const TICKET_EDITABLE_POST_BODY_LIMIT: usize = 1024 * 1024; @@ -15484,67 +15604,106 @@ fn authenticated_ticket_post_author( if let Some(source) = source { return Ok(format!("worker:{}/{}", source.runtime_id, source.worker_id)); } - let actor = context.actor.as_ref().ok_or_else(|| Error::WorkspacePermissionDenied( - "Ticket posts require an authenticated actor".into()))?; + let actor = context.actor.as_ref().ok_or_else(|| { + Error::WorkspacePermissionDenied("Ticket posts require an authenticated actor".into()) + })?; let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id)) .map_err(|error| Error::Config(format!("failed to encode post actor: {error}")))?; Ok(format!("workspace-user:{identity}")) } async fn scoped_get_ticket_post( - api: WorkspaceApi, workspace_id: String, id: String, event_ref: String, + api: WorkspaceApi, + workspace_id: String, + id: String, + event_ref: String, ) -> ApiResult { - api.host_executors.host()?.run(move || { - validate_workspace_scope(&api, &workspace_id)?; - let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?; - let ticket = browser_ticket_backend(&api)?.show(TicketIdOrSlug::Id(id)).map_err(Error::from)?; - let (sequence, post) = ticket.events.iter().enumerate().find(|(_, post)| post.event_ref == event_ref) - .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref)))?; - if post.body.as_str().len() > TICKET_EDITABLE_POST_BODY_LIMIT { - return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the full-post read limit")); - } - let mut detail = crate::authority::ticket_event_detail(sequence, post); - detail.body = Some(post.body.as_str().to_string()); - detail.body_truncated = false; - Ok(detail) - }).await + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?; + let ticket = browser_ticket_backend(&api)? + .show(TicketIdOrSlug::Id(id)) + .map_err(Error::from)?; + let (sequence, post) = ticket + .events + .iter() + .enumerate() + .find(|(_, post)| post.event_ref == event_ref) + .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref)))?; + if post.body.as_str().len() > TICKET_EDITABLE_POST_BODY_LIMIT { + return Err(settings_bad_request( + "ticket_post_too_large", + "Ticket post exceeds the full-post read limit", + )); + } + let mut detail = crate::authority::ticket_event_detail(sequence, post); + detail.body = Some(post.body.as_str().to_string()); + detail.body_truncated = false; + Ok(detail) + }) + .await } async fn scoped_edit_ticket_post( - api: WorkspaceApi, context: server_api::ServerRequestContext, - workspace_id: String, id: String, event_ref: String, request: server_api::TicketPostEditRequest, + api: WorkspaceApi, + context: server_api::ServerRequestContext, + workspace_id: String, + id: String, + event_ref: String, + request: server_api::TicketPostEditRequest, ) -> ApiResult> { - let headers = contract_request_headers(&context).map_err(|error| Error::Config(error.message))?; - let source = authenticated_ticket_worker_source(&headers, &context)?; - let author = authenticated_ticket_post_author(&context, source.as_ref())?; + let headers = + contract_request_headers(&context).map_err(|error| Error::Config(error.message))?; if request.body.len() > TICKET_EDITABLE_POST_BODY_LIMIT { - return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the edit limit")); + return Err(settings_bad_request( + "ticket_post_too_large", + "Ticket post exceeds the edit limit", + )); } - api.host_executors.host()?.run(move || { - validate_workspace_scope(&api, &workspace_id)?; - let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?; - let backend = browser_ticket_backend(&api)?; - let observed = backend.show(TicketIdOrSlug::Id(id.clone())).map_err(Error::from)?; - let post = observed.events.iter().find(|post| post.event_ref == event_ref) - .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref.clone())))?; - if crate::authority::ticket_post_body_digest(post.body.as_str()) != request.expected_body_digest { - return Err(Error::Ticket(ticket::TicketError::Conflict("stale Ticket post body digest".into())).into()); - } - let is_initial = post.is_initial; - // The digest is a transport observation; storage compares its exact body in the write transaction. - let (previous, current) = backend.edit_post_with_snapshots(TicketIdOrSlug::Id(id.clone()), ticket::TicketPostEdit { - event_ref, expected_body: post.body.clone(), body: MarkdownText::new(request.body), author, - }).map_err(Error::from)?; - notify_ticket_recipients(&api, &workspace_id, &id, previous.meta.workflow_state.as_str(), - current.meta.workflow_state.as_str(), source.clone()); - if is_initial && let Some(source) = source { - schedule_ticket_item_check(&api, ¤t, TicketItemCheckEdit::Edit { - title_changed: false, body_changed: true, previous_title: None, - previous_body: Some(previous.document.body.as_str().to_string()), body_replacement: None, - }, source); - } - browser_ticket_detail(&api, &id) - }).await + api.host_executors + .host()? + .run(move || { + validate_workspace_scope(&api, &workspace_id)?; + let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?; + let backend = browser_ticket_backend(&api)?; + let observed = backend + .show(TicketIdOrSlug::Id(id.clone())) + .map_err(Error::from)?; + let post = observed + .events + .iter() + .find(|post| post.event_ref == event_ref) + .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref.clone())))?; + if crate::authority::ticket_post_body_digest(post.body.as_str()) + != request.expected_body_digest + { + return Err(Error::Ticket(ticket::TicketError::Conflict( + "stale Ticket post body digest".into(), + )) + .into()); + } + // The digest is a transport observation; storage compares its exact body in the write transaction. + let expected_body = post.body.clone(); + execute_ticket_rest_operation_on_host( + &api, + &workspace_id, + headers, + Some(&context), + TicketBackendOperation::EditPost { + id: TicketIdOrSlug::Id(id.clone()), + edit: ticket::TicketPostEdit { + event_ref, + expected_body, + body: MarkdownText::new(request.body), + author: String::new(), + }, + }, + )?; + browser_ticket_detail(&api, &id) + }) + .await } async fn scoped_mark_ticket_ready_from_browser( @@ -15860,13 +16019,19 @@ fn execute_ticket_rest_operation_on_host( } else { verified_source.or(hinted_source) }; - if matches!(operation, TicketBackendOperation::Create { .. } | TicketBackendOperation::EditItem { .. } | TicketBackendOperation::AddEvent { .. }) - && let Some(context) = context + if matches!( + operation, + TicketBackendOperation::Create { .. } + | TicketBackendOperation::EditItem { .. } + | TicketBackendOperation::EditPost { .. } + | TicketBackendOperation::AddEvent { .. } + ) && let Some(context) = context { let author = authenticated_ticket_post_author(context, source.as_ref())?; match &mut operation { TicketBackendOperation::Create { input } => input.author = Some(author), TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author), + TicketBackendOperation::EditPost { edit, .. } => edit.author = author, TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author), _ => unreachable!(), } @@ -15987,6 +16152,35 @@ fn execute_ticket_rest_operation_on_host( } (TicketBackendOperationResult::Ticket(ticket), None) } + TicketBackendOperation::EditPost { id, edit } => { + let event_ref = edit.event_ref.clone(); + let (previous, current) = backend + .edit_post_with_snapshots(id, edit) + .map_err(Error::from)?; + // Check only an initial post, using the transaction's actual preimage. + ticket_item_check = source.clone().and_then(|source| { + previous + .events + .iter() + .find(|post| post.event_ref == event_ref && post.is_initial) + .map(|_| { + ( + source, + TicketItemCheckEdit::Edit { + title_changed: false, + body_changed: true, + previous_title: None, + previous_body: Some(previous.document.body.as_str().to_string()), + body_replacement: None, + }, + ) + }) + }); + ( + TicketBackendOperationResult::TicketSnapshots { previous, current }, + None, + ) + } operation => ( execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?, None, @@ -16005,6 +16199,16 @@ fn execute_ticket_rest_operation_on_host( source.clone(), ); } + } else if let TicketBackendOperationResult::TicketSnapshots { previous, current } = &result + { + notify_ticket_recipients( + api, + workspace_id, + ¤t.meta.id, + previous.meta.workflow_state.as_str(), + current.meta.workflow_state.as_str(), + source.clone(), + ); } else if let Some(target) = target && let Ok(ticket) = backend.show(target) { @@ -16021,6 +16225,7 @@ fn execute_ticket_rest_operation_on_host( if let Some((source_worker, edit)) = ticket_item_check { let checked_ticket = created_ticket.or_else(|| match &result { TicketBackendOperationResult::Ticket(ticket) => Some(ticket.clone()), + TicketBackendOperationResult::TicketSnapshots { current, .. } => Some(current.clone()), _ => None, }); if let Some(ticket) = checked_ticket { @@ -18310,6 +18515,7 @@ type WorkerMutationSource = RuntimeWorkerRef; fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketIdOrSlug> { match operation { TicketBackendOperation::EditItem { id, .. } + | TicketBackendOperation::EditPost { id, .. } | TicketBackendOperation::AddEvent { id, .. } | TicketBackendOperation::AddStateChanged { id, .. } | TicketBackendOperation::AddIntakeSummary { id, .. } @@ -18349,6 +18555,7 @@ fn bind_worker_ticket_operation_source( match operation { TicketBackendOperation::Create { input } => input.author = Some(author), TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author), + TicketBackendOperation::EditPost { edit, .. } => edit.author = author, TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author), TicketBackendOperation::AddStateChanged { change, .. } => change.author = Some(author), TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author), @@ -18373,6 +18580,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati match operation { TicketBackendOperation::Create { .. } => "create", TicketBackendOperation::EditItem { .. } => "edit_item", + TicketBackendOperation::EditPost { .. } => "edit_post", TicketBackendOperation::AddEvent { .. } => "add_event", TicketBackendOperation::AddStateChanged { .. } => "add_state_changed", TicketBackendOperation::UpdateCase { .. } => "case_update", @@ -47445,7 +47653,13 @@ mod tests { .unwrap(); let Json(_) = scoped_edit_ticket_item( State(api.clone()), - &server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()), + &server_request_context( + Some(test_browser_request_actor()), + None, + None, + None, + &HeaderMap::new(), + ), AxumPath(ScopedRecordPath { workspace_id: TEST_WORKSPACE_ID.to_string(), id: ticket_ref.id.clone(), @@ -52595,6 +52809,14 @@ mod tests { axum::http::HeaderValue::from_str(&source_worker.worker.worker_id).unwrap(), ); let response = build_inner_router(api.clone()) + .layer(Extension( + crate::worker_source::VerifiedRuntimeRequestSource { + authentication_binding_id: "fixture-binding".into(), + token_id_hash: None, + runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(), + worker_id: Some(source_worker.worker.worker_id.clone()), + }, + )) .oneshot( Request::builder() .method("POST") @@ -53470,6 +53692,263 @@ mod tests { ); } + #[tokio::test] + async fn ticket_post_native_route_binds_actor_and_returns_atomic_snapshots() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let mut input = ticket::NewTicket::new("Native post edit"); + input.body = MarkdownText::new("Original initial post"); + input.author = Some("original-author".into()); + let (reference, original) = backend.create_with_snapshot(input).unwrap(); + let app = build_inner_router(api.clone()).layer(Extension(test_browser_request_actor())); + let uri = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts", + reference.resource_key.as_ref().unwrap() + ); + let edit = json!({ + "event_ref": original.events[0].event_ref, + "expected_body": original.document.body, + "body": "Changed initial post", + "author": "forged-editor" + }); + let response = request_json( + app.clone(), + "PATCH", + &uri, + Some(edit.clone()), + StatusCode::OK, + ) + .await; + let (previous, current): (ticket::Ticket, ticket::Ticket) = + serde_json::from_value(response).unwrap(); + assert_eq!(previous, original); + assert_eq!(current, backend.show(reference.id.clone().into()).unwrap()); + assert_eq!(current.document.body.as_str(), "Changed initial post"); + assert_ne!( + ticket::ticket_content_digest(&previous), + ticket::ticket_content_digest(¤t) + ); + assert_eq!(current.events.len(), original.events.len()); + assert_eq!(current.events[0].author, original.events[0].author); + assert_eq!(current.events[0].at, original.events[0].at); + let context = server_request_context( + Some(test_browser_request_actor()), + None, + None, + None, + &HeaderMap::new(), + ); + let author = authenticated_ticket_post_author(&context, None).unwrap(); + assert_eq!( + current.events[0].edited_by.as_deref(), + Some(author.as_str()) + ); + assert!(current.events[0].edited_at.is_some()); + assert!(ticket_check_jobs_for_snapshot(&api, ¤t).is_empty()); + request_json(app, "PATCH", &uri, Some(edit), StatusCode::CONFLICT).await; + assert_eq!(backend.show(reference.id.into()).unwrap(), current); + } + + #[tokio::test] + async fn ticket_post_native_worker_route_checks_only_initial_with_atomic_preimage() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let source = spawn_ticket_check_source(&api, "post-editor"); + let backend = browser_ticket_backend(&api).unwrap(); + let mut input = ticket::NewTicket::new("Worker post edit"); + input.body = MarkdownText::new("Initial requirements"); + input.author = Some("creator".into()); + let reference = backend.create(input).unwrap(); + let mut comment = NewTicketEvent::new(TicketEventKind::Comment, "Discussion"); + comment.author = Some("commenter".into()); + backend + .add_event(reference.id.clone().into(), comment) + .unwrap(); + let original = backend.show(reference.id.clone().into()).unwrap(); + // Below proof middleware, inject the verified Worker subject; no identity hints required. + let app = build_inner_router(api.clone()).layer(Extension( + crate::worker_source::VerifiedRuntimeRequestSource { + authentication_binding_id: "fixture-binding".into(), + token_id_hash: None, + runtime_id: source.runtime_id.clone(), + worker_id: Some(source.worker_id.clone()), + }, + )); + let uri = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts", reference.id); + let editor = format!("worker:{}/{}", source.runtime_id, source.worker_id); + let mut latest = original.clone(); + for (index, body) in [ + (1, "Changed discussion"), + (0, "Changed requirements"), + (0, "Final requirements"), + ] { + let edit = json!({ "event_ref": latest.events[index].event_ref, + "expected_body": latest.events[index].body, "body": body, "author": "forged-editor" }); + let response = request_json( + app.clone(), + "PATCH", + &uri, + Some(edit.clone()), + StatusCode::OK, + ) + .await; + let (previous, current): (ticket::Ticket, ticket::Ticket) = + serde_json::from_value(response).unwrap(); + assert_eq!(previous, latest); + assert_eq!( + current.events[index].edited_by.as_deref(), + Some(editor.as_str()) + ); + assert_eq!(current.events[index].author, original.events[index].author); + assert_eq!(current.events[index].at, original.events[index].at); + assert_eq!(current.events.len(), original.events.len()); + if index == 0 { + let jobs = ticket_check_jobs_for_snapshot(&api, ¤t); + assert_eq!(jobs.len(), 1); + let checked = ticket_item_checker::parse_input(&jobs[0].request.input).unwrap(); + assert_eq!(checked.body, current.document.body.as_str()); + assert_eq!( + checked.content_digest, + ticket_item_checker::content_digest(¤t) + ); + assert!(matches!(checked.edit, TicketItemCheckEdit::Edit { + title_changed: false, body_changed: true, previous_body: Some(ref body), .. + } if body == previous.document.body.as_str())); + } else { + assert_eq!( + ticket::ticket_content_digest(&previous), + ticket::ticket_content_digest(¤t) + ); + assert!(ticket_check_jobs_for_snapshot(&api, ¤t).is_empty()); + } + request_json(app.clone(), "PATCH", &uri, Some(edit), StatusCode::CONFLICT).await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current); + latest = current; + } + tokio::time::timeout(std::time::Duration::from_secs(5), async { + api.host_executors.host().unwrap().drain().await.unwrap(); + api.host_executors.cleanup().unwrap().drain().await.unwrap(); + api.worker_projection.shutdown_async().await; + api.store.db_executor().drain().await.unwrap(); + }) + .await + .expect("post fixture must drain accepted work"); + } + + #[tokio::test] + async fn ticket_post_browser_routes_read_full_body_and_cas_initial_and_comment() { + let dir = tempfile::tempdir().unwrap(); + let api = test_api(dir.path()).await; + let backend = browser_ticket_backend(&api).unwrap(); + let mut input = ticket::NewTicket::new("Browser posts"); + input.body = MarkdownText::new("Initial post"); + input.author = Some("creator".into()); + let reference = backend.create(input).unwrap(); + let full_body = "長い comment\n".repeat(4000); + let mut comment = NewTicketEvent::new(TicketEventKind::Comment, full_body.clone()); + comment.author = Some("commenter".into()); + backend + .add_event(reference.id.clone().into(), comment) + .unwrap(); + backend + .add_event( + reference.id.clone().into(), + NewTicketEvent::new(TicketEventKind::Decision, "Immutable decision"), + ) + .unwrap(); + let original = backend.show(reference.id.clone().into()).unwrap(); + let app = build_inner_router(api.clone()).layer(Extension(test_browser_request_actor())); + let base = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts", + reference.resource_key.as_ref().unwrap() + ); + let mut latest = original.clone(); + for (index, replacement) in [(1, "Changed discussion"), (0, "Changed initial")] { + let uri = format!("{base}/{}", latest.events[index].event_ref); + let read = request_json(app.clone(), "GET", &uri, None, StatusCode::OK).await; + let post: server_api::TicketEventDetail = serde_json::from_value(read).unwrap(); + assert_eq!( + post.body.as_deref(), + Some(latest.events[index].body.as_str()) + ); + assert!(!post.body_truncated); + assert!(post.editable); + assert_eq!( + post.body_digest, + crate::authority::ticket_post_body_digest(latest.events[index].body.as_str()) + ); + let edit = json!({ "expected_body_digest": post.body_digest, "body": replacement }); + let response = request_json( + app.clone(), + "PATCH", + &uri, + Some(edit.clone()), + StatusCode::OK, + ) + .await; + let detail: TicketDetail = serde_json::from_value(response).unwrap(); + let current = backend.show(reference.id.clone().into()).unwrap(); + assert_eq!(detail.id, reference.id); + assert_eq!(current.events.len(), original.events.len()); + assert_eq!(current.events[index].author, original.events[index].author); + assert_eq!(current.events[index].at, original.events[index].at); + assert!( + current.events[index] + .edited_by + .as_ref() + .unwrap() + .starts_with("workspace-user:") + ); + assert!(current.events[index].edited_at.is_some()); + assert_eq!(current.events[index].body.as_str(), replacement); + if index == 0 { + assert_eq!(current.document.body.as_str(), replacement); + assert_ne!( + ticket::ticket_content_digest(&latest), + ticket::ticket_content_digest(¤t) + ); + } else { + assert_eq!(current.document.body, original.document.body); + assert_eq!( + ticket::ticket_content_digest(&latest), + ticket::ticket_content_digest(¤t) + ); + } + request_json(app.clone(), "PATCH", &uri, Some(edit), StatusCode::CONFLICT).await; + assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current); + latest = current; + } + let system_uri = format!("{base}/{}", original.events[2].event_ref); + let system = request_json(app.clone(), "GET", &system_uri, None, StatusCode::OK).await; + assert_eq!(system["editable"], false); + request_json( + app.clone(), + "PATCH", + &system_uri, + Some(json!({"expected_body_digest": system["body_digest"], "body": "forged"})), + StatusCode::CONFLICT, + ) + .await; + request_json( + app.clone(), + "GET", + &format!("{base}/missing"), + None, + StatusCode::NOT_FOUND, + ) + .await; + let other = backend + .create(ticket::NewTicket::new("Other ticket")) + .unwrap(); + let foreign = format!( + "/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts/{}", + other.id, original.events[0].event_ref + ); + request_json(app, "GET", &foreign, None, StatusCode::NOT_FOUND).await; + assert_eq!(backend.show(reference.id.into()).unwrap(), latest); + } + #[tokio::test] async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() { let dir = tempfile::tempdir().unwrap(); @@ -53539,7 +54018,13 @@ mod tests { let Json(edited) = scoped_edit_ticket_item( State(api.clone()), - &server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()), + &server_request_context( + Some(test_browser_request_actor()), + None, + None, + None, + &HeaderMap::new(), + ), AxumPath(path()), Json(server_api::BrowserEditTicketRequest { title: Some("Browser Ticket API edited".to_string()), @@ -53577,7 +54062,13 @@ mod tests { let Json(commented) = scoped_append_ticket_event( State(api.clone()), - &server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()), + &server_request_context( + Some(test_browser_request_actor()), + None, + None, + None, + &HeaderMap::new(), + ), AxumPath(path()), Json(server_api::BrowserAppendTicketEventRequest { role: server_api::BrowserTicketThreadRole::Comment, @@ -55228,9 +55719,14 @@ mod tests { crate::store::configure_sqlite(&conn).unwrap(); conn.execute( "INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, + workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit - ) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', '', 'open', 1)", + ) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', 'open', 1)", + rusqlite::params![api.config.workspace_id, ticket_id], + ) + .unwrap(); + conn.execute( + "INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES(?1,?2,-1,?2 || ':-1','comment','')", rusqlite::params![api.config.workspace_id, ticket_id], ) .unwrap(); @@ -60402,7 +60898,7 @@ mod tests { { let dir = tempfile::tempdir().unwrap(); let api = test_api(dir.path()).await; - let app = build_inner_router(api); + let app = build_inner_router(api).layer(Extension(test_browser_request_actor())); let response = app .clone() @@ -63348,11 +63844,13 @@ mod tests { .execute_batch( r#" INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, + workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit ) VALUES - ('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); + ('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 typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) +SELECT workspace_id,ticket_id,-1,ticket_id || ':-1','comment','' FROM typed_tickets WHERE workspace_id='0192f0e8-4d84-7d6e-a000-000000000001'; INSERT INTO workspace_resource_keys ( workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at ) VALUES diff --git a/crates/workspace-server/src/store.rs b/crates/workspace-server/src/store.rs index 3d939a6a9..71176e3df 100644 --- a/crates/workspace-server/src/store.rs +++ b/crates/workspace-server/src/store.rs @@ -14004,6 +14004,7 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> { let tx = conn.unchecked_transaction()?; ticket::migrate_sqlite_ticket_schema_in_transaction(&tx) .map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?; + assignment_delivery::install_post_inputs(&tx)?; tx.execute( "INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')", [], @@ -14025,6 +14026,7 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> { fn create_latest_workspace_schema(conn: &Connection) -> Result<()> { create_workspace_schema_structure(conn)?; assignment_delivery::install(conn)?; + assignment_delivery::install_post_inputs(conn)?; ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { Error::Store(format!( "fresh Workspace Ticket schema verification failed: {error}" @@ -14260,6 +14262,26 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> { } } } + if current_schema_version(conn)? >= 94 { + for (kind, name) in [ + ("table", "ticket_assignment_content_inputs"), + ("trigger", "ticket_assignment_content_input_no_update"), + ("trigger", "ticket_assignment_content_input_no_delete"), + ("trigger", "ticket_assignment_title_input"), + ("trigger", "ticket_assignment_initial_post_input"), + ] { + let present: bool = conn.query_row( + "SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type=?1 AND name=?2)", + params![kind, name], + |row| row.get(0), + )?; + if !present { + return Err(Error::Store(format!( + "Assignment content input constraint {name} is missing" + ))); + } + } + } Ok(()) } @@ -20146,16 +20168,18 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx let (_dir, path) = legacy_case_workspace(90); let conn = Connection::open(&path).unwrap(); configure_sqlite(&conn).unwrap(); + // Keep the collision table valid for dependent triggers during the shared + // Ticket table rebuild. Only Workspace phase two must fail on DROP VIEW. 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(); + CREATE TABLE ticket_active_worker_assignments AS SELECT * FROM ticket_current_worker_assignments WHERE 0;").unwrap(); install_retained_ticket_tags8(&conn); let _tags = seed_retained_classification(&conn, "case-workspace", "terminal"); let tags_before = classification_rows(&conn); let auth_before = fixture_auth_rows(&conn); let error = migrate_ticket_cases_v90_to_v91(&conn).unwrap_err(); - assert!(error.to_string().contains("DROP TABLE")); + assert!(error.to_string().contains("DROP TABLE"), "{error}"); assert_eq!(current_schema_version(&conn).unwrap(), 90); assert_eq!(fixture_auth_rows(&conn), auth_before); assert_eq!(classification_rows(&conn), tags_before); @@ -21142,10 +21166,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx FROM typed_tickets WHERE ticket_id='ticket'", [], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?; if state == "closed" { - assert_eq!((persisted.as_str(), events, assignments, operations), (state,0,0,0)); + assert_eq!((persisted.as_str(), events, assignments, operations), (state,1,0,0)); } else { - 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!((persisted.as_str(), events, assignments, operations), ("open",2,1,1)); + let transition: (Option,Option,String) = conn.query_row("SELECT from_state,to_state,kind FROM typed_ticket_events WHERE kind='assignment'", [], |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"); } @@ -21404,12 +21428,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx conn.execute_batch( r#" INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, + workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit ) VALUES - ('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); + ('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 typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) +SELECT workspace_id,ticket_id,-1,ticket_id || ':-1','comment','' FROM typed_tickets WHERE workspace_id='workspace-a'; INSERT INTO worker_registry ( workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at ) VALUES @@ -22032,9 +22058,11 @@ INSERT INTO workspace_signing_identities ( '2026-01-01', NULL, '2026-01-01' ); INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, + workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit -) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', 'open', 1); +) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', 'open', 1); +INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) +VALUES ('workspace-a','ticket-a',-1,'ticket-a:-1','comment',''); INSERT INTO worker_registry ( workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at ) VALUES ( @@ -22062,9 +22090,11 @@ INSERT INTO workspace_signing_identities ( '2026-01-01', NULL, '2026-01-01' ); INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, + workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit -) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', '', 'open', 1); +) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', 'open', 1); +INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) +VALUES ('workspace-b','ticket-a',-1,'ticket-a:-1','comment',''); 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/ticket_post_upgrade_tests.rs b/crates/workspace-server/src/ticket_post_upgrade_tests.rs index b48cbc7c6..913b5c524 100644 --- a/crates/workspace-server/src/ticket_post_upgrade_tests.rs +++ b/crates/workspace-server/src/ticket_post_upgrade_tests.rs @@ -84,6 +84,7 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au let markers = stored_fixture_rows(conn,"ticket_schema_migrations"); assert_eq!(&markers[..old_markers.len()],old_markers.as_slice()); assert_eq!(markers.len(),old_markers.len()+1); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs",[],|r|r.get::<_,i64>(0))?,0,"migration does not turn historical edits into new inputs"); let initial: (String,Option,Option,String,String) = conn.query_row("SELECT e.body,t.created_by,e.author,e.kind,e.event_ref FROM typed_tickets t JOIN typed_ticket_events e USING(workspace_id,ticket_id) WHERE t.ticket_id='authored' AND e.event_index=-1",[],|row|Ok((row.get(0)?,row.get(1)?,row.get(2)?,row.get(3)?,row.get(4)?)))?; assert_eq!(initial,("Current requirements after edits".into(),Some("creator".into()),Some("creator".into()),"comment".into(),"authored:-1".into())); let archive: Vec<(i64,String,i64)> = conn.prepare("SELECT event_index,event_ref,archived FROM typed_ticket_events WHERE ticket_id='authored' ORDER BY event_index")?.query_map([],|r|Ok((r.get(0)?,r.get(1)?,r.get(2)?)))?.collect::>()?; @@ -273,6 +274,7 @@ fn ticket_posts_fresh_and_migrated_schema_constraints_match() { "typed_ticket_events", "typed_ticket_event_attributes", "typed_ticket_event_references", + "ticket_assignment_content_inputs", ] { for query in [ format!( diff --git a/crates/workspace-server/src/workdir_removal.rs b/crates/workspace-server/src/workdir_removal.rs index c8a80958a..519541ef2 100644 --- a/crates/workspace-server/src/workdir_removal.rs +++ b/crates/workspace-server/src/workdir_removal.rs @@ -1298,10 +1298,14 @@ mod tests { )?; conn.execute( "INSERT INTO typed_tickets ( - workspace_id, ticket_id, slug, title, status, kind, priority, body, + workspace_id, ticket_id, slug, title, status, kind, priority, workflow_state, workflow_state_explicit ) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', - 'task', 'normal', '', 'open', 1)", + 'task', 'normal', 'open', 1)", + [], + )?; + conn.execute( + "INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('workspace-a','ticket-a',-1,'ticket-a:-1','comment','')", [], )?; conn.execute( diff --git a/crates/workspace-server/src/workspace_deletion.rs b/crates/workspace-server/src/workspace_deletion.rs index 5003d4153..13a7fe9d1 100644 --- a/crates/workspace-server/src/workspace_deletion.rs +++ b/crates/workspace-server/src/workspace_deletion.rs @@ -48,6 +48,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[ "ticket_assignment_delivery_acknowledgements", "ticket_assignment_delivery_requests", "ticket_assignment_deliveries", + "ticket_assignment_content_inputs", "ticket_assignment_delivery_origins", "ticket_assignment_operation_fences", "ticket_assignment_operations", @@ -488,6 +489,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore { ("ticket_assignment_delivery_requests", "ticket_assignment_delivery_request_no_delete"), ("ticket_assignment_delivery_acknowledgements", "ticket_assignment_delivery_ack_no_delete"), ("ticket_assignment_delivery_origins", "ticket_assignment_delivery_origin_no_delete"), + ("ticket_assignment_content_inputs", "ticket_assignment_content_input_no_delete"), ("ticket_assignment_operation_fences", "ticket_assignment_operation_fence_no_delete"), ("typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_history_no_delete"), ("typed_ticket_assignment_tracking_operations", "typed_ticket_assignment_tracking_operations_no_delete"), @@ -1016,6 +1018,15 @@ mod tests { false, ) .unwrap(); + let initial = backend + .edit_item( + initial.meta.id.clone().into(), + ticket::TicketItemEdit { + title: Some("Changed case evidence".into()), + ..Default::default() + }, + ) + .unwrap(); // Seed domain handling evidence under a transaction. This fixture // exercises owner purge, not singleton authentication or delivery. store @@ -1091,7 +1102,7 @@ mod tests { } let assert_immutable = |workspace: &str| { store.with_conn(|conn| { - for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_worker_assignment_events", "typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_operations", "ticket_assignment_operations"] { + for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_worker_assignment_events", "typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_operations", "ticket_assignment_operations", "ticket_assignment_content_inputs"] { 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()); } @@ -1176,6 +1187,7 @@ mod tests { }; assert_eq!(count, expected, "rollback must restore {table}"); } + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs WHERE workspace_id='workspace-a'", [], |row| row.get::<_,i64>(0))?, 1, "rollback restores content inputs"); conn.execute_batch("DROP TRIGGER reject_workspace_purge;")?; Ok(()) }) @@ -1202,6 +1214,8 @@ mod tests { assert_immutable("workspace-b"); store .with_conn(|conn| { + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs WHERE workspace_id='workspace-a'", [], |row| row.get::<_,i64>(0))?, 0); + assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs WHERE workspace_id='workspace-b'", [], |row| row.get::<_,i64>(0))?, 1); for (table, before) in tables.iter().zip(&other_before) { let count: i64 = conn.query_row( &format!( diff --git a/openapi/server-api.json b/openapi/server-api.json index 27f2c890a..57106c86b 100644 --- a/openapi/server-api.json +++ b/openapi/server-api.json @@ -12941,6 +12941,30 @@ }, "type": "object" }, + "TicketPostEdit": { + "description": "A compare-and-swap edit of a real comment post. The host supplies authenticated author.", + "properties": { + "author": { + "type": "string" + }, + "body": { + "$ref": "#/components/schemas/MarkdownText" + }, + "event_ref": { + "type": "string" + }, + "expected_body": { + "$ref": "#/components/schemas/MarkdownText" + } + }, + "required": [ + "event_ref", + "expected_body", + "body", + "author" + ], + "type": "object" + }, "TicketPostEditRequest": { "additionalProperties": false, "properties": { @@ -14042,6 +14066,19 @@ ], "type": "object" }, + "Tuple_of_Ticket_and_Ticket": { + "maxItems": 2, + "minItems": 2, + "prefixItems": [ + { + "$ref": "#/components/schemas/Ticket" + }, + { + "$ref": "#/components/schemas/Ticket" + } + ], + "type": "array" + }, "UpdateRemoteRuntimeRequest": { "additionalProperties": false, "properties": { @@ -17198,7 +17235,7 @@ "info": { "title": "Yoi Server API", "version": "0.1.0", - "x-yoi-source-digest": "sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214" + "x-yoi-source-digest": "sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904" }, "openapi": "3.1.0", "paths": { @@ -35988,6 +36025,123 @@ ] } }, + "/api/w/{workspace_id}/tickets/{id}/posts": { + "patch": { + "operationId": "ticket_record_post_edit", + "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/TicketPostEdit" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Tuple_of_Ticket_and_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" + }, + "500": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RepositoryApiError" + } + } + }, + "description": "Error response" + } + }, + "security": [ + { + "bearerAuth": [] + }, + { + "browserSession": [] + } + ] + } + }, "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}": { "get": { "operationId": "ticket_post_get", diff --git a/web/workspace/src/lib/generated/companion-api.ts b/web/workspace/src/lib/generated/companion-api.ts index f0cf6e62c..d1ff43cbe 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:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) +// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) // 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 701d49e01..ae074cee0 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:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214) +// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) // 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 0931ce7df..1f1c55747 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:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) +// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) // 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 50e7e2da7..506a3c822 100644 --- a/web/workspace/src/lib/generated/ticket-api.ts +++ b/web/workspace/src/lib/generated/ticket-api.ts @@ -1,6 +1,6 @@ // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // Input: openapi/server-api.json -// Input contract: 0.1.0 (sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214) +// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) // 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 diff --git a/web/workspace/src/lib/generated/worker-launch-api.ts b/web/workspace/src/lib/generated/worker-launch-api.ts index 6ec34e9cd..e2482f37b 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:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb) +// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904) // 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/tickets/ticket-operations.browser.spec.ts b/web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts index de12d5186..070cc8cea 100644 --- a/web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts +++ b/web/workspace/src/lib/workspace/tickets/ticket-operations.browser.spec.ts @@ -54,23 +54,6 @@ function reads(path: string, row: TicketAssignmentTrackingProjection) { if (path.includes("/assignment-tracking/assignments")) { return Response.json({ items: [row], next_assignment_id: null }); } - if (path.includes("/assignment-diagnostics")) { - return Response.json({ - assignment_id: row.assignment_id, - current: true, - actionable: true, - input_origin_known: true, - inputs: [{ - input_event_id: "input", - delivery_id: "delivery", - notification: "request_persisted", - acceptance_outcome_unknown: true, - }], - starts: [], - next_input_event_id: null, - next_start_operation_id: null, - }); - } throw new Error(`Unexpected read ${path}`); } test("an answer response loss keeps exact assignment/event/key/body for retry even when observed responsibility changes", async () => { @@ -126,9 +109,11 @@ test("an answer response loss keeps exact assignment/event/key/body for retry ev test("a done reissue persists before a failed refresh and does not pretend delivery or execution succeeded", async () => { const row = assignment("delegation-1", "done"); const sent: unknown[] = []; + const paths: string[] = []; vi.stubGlobal( "fetch", vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { + paths.push(String(input)); if (init?.method === "PUT") { sent.push(JSON.parse(String(init.body))); return Response.json({ @@ -164,13 +149,14 @@ test("a done reissue persists before a failed refresh and does not pretend deliv expect(sent).toHaveLength(1); expect(sent[0]).toMatchObject({ expected_assignment_id: "delegation-1" }); expect(view.queryByText("Retry exact operation")).toBeNull(); - expect(view.container.textContent).toContain("Outcome unknown"); - expect(view.container.textContent).toContain( - "Outcome unknown — may already be accepted", - ); - expect(view.container.textContent).toContain( - "No work start records observed", + await waitFor(() => + expect(view.getByRole("alert").textContent).toContain( + "Operation saved; latest projection unavailable", + ) ); + expect(view.container.querySelector(".ticket-delivery")).toBeNull(); + expect(view.queryByText("Delivery and execution")).toBeNull(); + expect(paths.some((path) => path.includes("assignment-diagnostics"))).toBe(false); expect(view.container.textContent).toContain("Self-reported handling: done"); }); test("classification conflict keeps stable IDs and the original observed set across an exact retry", async () => {