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.
This commit is contained in:
2026-10-14 14:49:20 +09:00
parent 8611078058
commit da378fc902
22 changed files with 1311 additions and 178 deletions
+57
View File
@@ -601,6 +601,22 @@ impl BackendWorkspaceProductClient {
} }
impl TicketBackend for 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( fn list_workspace_tags(
&self, &self,
request: ticket::TagListRequest, request: ticket::TagListRequest,
@@ -994,6 +1010,47 @@ mod tests {
(format!("http://{address}"), receiver, handle) (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::<ticket::TicketPostEdit>(body).unwrap(),
edit
);
server.join().unwrap();
}
#[test] #[test]
fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() { fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+12
View File
@@ -312,6 +312,8 @@ impl_openapi_schema!(
CreateTicketRelationRequest, CreateTicketRelationRequest,
DefaultIntakeReadyBodyRequest, DefaultIntakeReadyBodyRequest,
EditTicketRecordItemRequest, EditTicketRecordItemRequest,
EditTicketPostRequest,
TicketPostSnapshots,
MergeEvent, MergeEvent,
MergeRequestDetailResponse, MergeRequestDetailResponse,
MergeRequestListQuery, MergeRequestListQuery,
@@ -3085,6 +3087,14 @@ pub trait ServerApi {
#[path] id: String, #[path] id: String,
#[body] request: EditTicketRecordItemRequest, #[body] request: EditTicketRecordItemRequest,
) -> Result<TicketRecord, RepositoryApiError>; ) -> Result<TicketRecord, RepositoryApiError>;
#[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<TicketPostSnapshots, RepositoryApiError>;
#[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)] #[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( async fn ticket_dependency_check(
&self, &self,
@@ -8072,6 +8082,8 @@ transparent_ticket_dto!(
); );
transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket); transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket);
transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit); 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!(TicketThreadEventRequest, ticket::NewTicketEvent);
transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary); transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary);
transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation); transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation);
+2 -8
View File
@@ -599,14 +599,8 @@ fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_recei
reopened.events[0] reopened.events[0]
.attributes .attributes
.retain(|key, _| matches!(key.as_str(), "event_id" | "event_sequence")); .retain(|key, _| matches!(key.as_str(), "event_id" | "event_sequence"));
// The frozen v9 copy has no persisted event_id attributes. Its published // The frozen SQLite loader published ticket_id:event_index identities.
// fallback was a zero-based ordinal, not the source v12 persisted identity. // These remain identical after migration, without replacing them with display ordinals.
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());
}
let path = temp.path().join("retained9.db"); let path = temp.path().join("retained9.db");
let connection = Connection::open(&path).unwrap(); let connection = Connection::open(&path).unwrap();
schema7(&connection); schema7(&connection);
@@ -37,6 +37,14 @@ struct ModelTicketQueryItem {
merge_request: Option<ModelMergeRequest>, merge_request: Option<ModelMergeRequest>,
} }
#[derive(Debug, serde::Deserialize, Serialize)]
struct ModelTicketTarget {
repository_key: String,
// Keep an unspecified selector explicit in the model-facing read contract.
ref_selector: Option<String>,
access: ticket::TicketTargetAccess,
}
#[derive(Debug, Serialize)] #[derive(Debug, Serialize)]
pub(super) struct ModelTicketDetail { pub(super) struct ModelTicketDetail {
ticket: String, ticket: String,
@@ -45,6 +53,7 @@ pub(super) struct ModelTicketDetail {
body: String, body: String,
body_truncated: bool, body_truncated: bool,
initial_post: ModelTicketEvent, initial_post: ModelTicketEvent,
targets: Vec<ModelTicketTarget>,
created_by: Option<String>, created_by: Option<String>,
state: ticket::TicketState, state: ticket::TicketState,
closure_operation_key: Option<String>, closure_operation_key: Option<String>,
@@ -351,6 +360,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
root.get("initial_post") root.get("initial_post")
.ok_or("Ticket detail response is missing initial_post")?, .ok_or("Ticket detail response is missing initial_post")?,
)?, )?,
targets: typed_field(root, "targets")?,
created_by: optional_string(root, "created_by")?, created_by: optional_string(root, "created_by")?,
state: typed_field(root, "state")?, state: typed_field(root, "state")?,
closure_operation_key: typed_field(root, "closure_operation_key")?, closure_operation_key: typed_field(root, "closure_operation_key")?,
@@ -855,7 +865,7 @@ mod tests {
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() { fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
for state in ["open", "closed"] { for state in ["open", "closed"] {
let projected = project_ticket_detail(json!({ let projected = project_ticket_detail(json!({
"resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "body_truncated": false, "initial_post": post_fixture(true, "Decision"), "resource_key": "T-718", "assignment_tracking": [], "tags": [], "content_digest": "rev-1", "title": "No Git required", "body": "Decision", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Decision"),
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"state": state, "events": [post_fixture(false, "No Git needed")], "state": state, "events": [post_fixture(false, "No Git needed")],
"linked_objectives": [], "assignments": [], "implementation_reports": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
@@ -898,7 +908,7 @@ mod tests {
}); });
let response = json!({ let response = json!({
"assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}], "assignment_tracking": [], "tags": [{"workspace_id":"ws", "id":"tag-1", "name":"closed", "last_operation_key":"tag-create", "deleted":false}],
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "body_truncated": false, "initial_post": post_fixture(true, "Requirements"), "resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Requirements"),
"state": "open", "closure_operation_key": "close-1", "current_closure": null, "state": "open", "closure_operation_key": "close-1", "current_closure": null,
"closure_history": history, "legacy_handoff": legacy, "closure_history": history, "legacy_handoff": legacy,
"events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [], "events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
@@ -1049,7 +1059,7 @@ mod tests {
"content_digest": "ticket-content-digest", "content_digest": "ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Queued Submit", "title": "Queued Submit",
"body": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"), "body": "Body", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Body"),
"state": "open", "state": "open",
"readiness": null, "readiness": null,
"priority": "P2", "priority": "P2",
@@ -1153,7 +1163,7 @@ mod tests {
"content_digest": "current-ticket-content-digest", "content_digest": "current-ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null, "closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Source evidence", "title": "Source evidence",
"body": "Body", "body_truncated": false, "initial_post": post_fixture(true, "Body"), "body": "Body", "body_truncated": false, "targets": [], "initial_post": post_fixture(true, "Body"),
"state": "open", "state": "open",
"events": [], "events": [],
"linked_objectives": [], "linked_objectives": [],
@@ -1337,7 +1347,7 @@ mod tests {
let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}); let closure = json!({"operation_key":"close-1", "reason":"not_planned", "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"});
let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]); let history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]);
let detail = json!({ let detail = json!({
"resource_key":"T-739", "title":"Case", "body":"Requirements", "body_truncated":false, "initial_post":post_fixture(true, "Requirements"), "created_by":"workspace-user:creator", "content_digest":"content-1", "resource_key":"T-739", "title":"Case", "body":"Requirements", "body_truncated":false, "targets":[], "initial_post":post_fixture(true, "Requirements"), "created_by":"workspace-user:creator", "content_digest":"content-1",
"state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure, "state":"closed", "assignment_tracking": [tracking], "tags":[tag], "closure_operation_key":"close-1", "current_closure":closure,
"legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[] "legacy_handoff":null, "closure_history":history, "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]
}); });
+118 -3
View File
@@ -101,7 +101,7 @@ impl WorkspaceTicketReadKind {
"Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata." "Query authoritative Workspace Tickets with bounded typed filters, stable snippets, evidence summaries, and cursor metadata."
} }
Self::Show => { Self::Show => {
"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 { if status == 200 {
let (previous, current) = result.unwrap(); let (previous, current) = result.unwrap();
assert_eq!(previous.meta.id, record.meta.id); assert_eq!(previous.meta.id, "T-718");
assert_eq!(current.meta.id, record.meta.id); assert_eq!(current.meta.id, "T-718");
} else { } else {
assert!(result.unwrap_err().to_string().contains("409")); 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] #[test]
fn query_schema_allows_only_open_and_closed_case_states() { fn query_schema_allows_only_open_and_closed_case_states() {
let client = decision_client(200, TicketState::Open); let client = decision_client(200, TicketState::Open);
@@ -139,7 +139,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations()
assert_eq!(body[field], input[field]); 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 client = recording(detail);
let (_, tool) = let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)(); workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
@@ -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;
@@ -126,6 +126,12 @@ WHERE json_extract(h.source_context_json,'$.kind')='migration'
Ok(()) 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#" const INPUTS_SQL: &str = r#"
WITH eligible AS ( WITH eligible AS (
SELECT a.*,o.input_event_id,o.after_ticket_event_index 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 e.kind NOT LIKE 'assignment_delivery%'
AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking') 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) 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, 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 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>, assignment_id: Option<&str>,
input_event_id: Option<&str>, input_event_id: Option<&str>,
) -> Result<Vec<TicketAssignmentDelivery>> { ) -> Result<Vec<TicketAssignmentDelivery>> {
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 mut statement = conn.prepare(&sql)?;
let rows = statement.query_map( let rows = statement.query_map(
params![workspace_id, limit as i64, assignment_id, input_event_id], 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 // Same input selection as delivery, plus immutable delivered history. Never
// SELECT payload/source/content into the public diagnostic projection. // SELECT payload/source/content into the public diagnostic projection.
const DIAGNOSTIC_INPUTS_SQL: &str = r#" 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 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 WHERE o.workspace_id=?1 AND o.assignment_id=?3
UNION 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 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 WHERE d.workspace_id=?1 AND d.assignment_id=?3 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 UNION
SELECT 'ticket-event:' || e.event_index,e.event_index 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 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 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) 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) 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
) )
"#; "#;
@@ -6,7 +6,7 @@ fn delivery_store_external_event(
state_field: Option<&str>, state_field: Option<&str>,
) { ) {
store.with_conn(|conn| { 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(()) Ok(())
}).unwrap(); }).unwrap();
} }
@@ -651,7 +651,7 @@ fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_l
); );
store store
.with_conn(|conn| { .with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 93); assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!( assert_eq!(
stored_fixture_rows(conn, "ticket_worker_assignments"), stored_fixture_rows(conn, "ticket_worker_assignments"),
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(); let wire = serde_json::to_string(&first).unwrap();
assert!(!wire.contains("tracking-binding") && !wire.contains("External resource")); 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::<Vec<_>>());
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}");
}
}
+79 -16
View File
@@ -762,12 +762,19 @@ impl SqliteWorkspaceAuthority {
let id = ticket.meta.id.as_str(); let id = ticket.meta.id.as_str();
let dependency_check = let dependency_check =
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?; 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()))?; .ok_or_else(|| Error::Store("Ticket initial post is missing".into()))?;
let initial_post = ticket_event_detail(0, initial); let initial_post = ticket_event_detail(0, initial);
let body = initial_post.body.clone().unwrap_or_default(); let body = initial_post.body.clone().unwrap_or_default();
let body_truncated = initial_post.body_truncated; let body_truncated = initial_post.body_truncated;
let subsequent_events = ticket.events.iter().filter(|event| !event.is_initial).collect::<Vec<_>>(); let subsequent_events = ticket
.events
.iter()
.filter(|event| !event.is_initial)
.collect::<Vec<_>>();
let event_limit = request let event_limit = request
.event_limit .event_limit
.unwrap_or(TICKET_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 { pub(crate) fn ticket_post_body_digest(body: &str) -> String {
use sha2::{Digest, Sha256}; 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 { 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 { TicketEventDetail {
is_initial: event.is_initial, is_initial: event.is_initial,
edited_at: event.edited_at.clone(), edited_at: event.edited_at.clone(),
@@ -1946,6 +1963,11 @@ pub(crate) fn merge_request_summary(
} }
fn substantive_item_edit(event: &TicketEvent) -> bool { 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" { if event.kind.as_str() != "item_edit" {
return false; return false;
} }
@@ -3041,6 +3063,10 @@ mod tests {
attributes.insert("changes".to_string(), changes.to_string()); attributes.insert("changes".to_string(), changes.to_string());
} }
TicketEvent { TicketEvent {
event_ref: format!("event:{kind}:{at}"),
is_initial: false,
edited_at: None,
edited_by: None,
kind: ticket::TicketEventKind::Other(kind.to_string()), kind: ticket::TicketEventKind::Other(kind.to_string()),
author: Some("coder".to_string()), author: Some("coder".to_string()),
at: Some(at.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( fn unavailable_source(
code: &str, code: &str,
) -> std::result::Result<String, server_api::MergeRequestRefDiagnostic> { ) -> std::result::Result<String, server_api::MergeRequestRefDiagnostic> {
@@ -3615,30 +3675,29 @@ mod tests {
.execute_batch( .execute_batch(
r#" r#"
INSERT INTO typed_tickets ( 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 created_at, updated_at, workflow_state, workflow_state_explicit
) VALUES ) VALUES
('workspace-test', '00000000001J2', '00000000001J2', 'Read bridge', 'open', 'ticket', 'P2', ('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), '2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'open', 1),
('workspace-test', '00000000001J5', '00000000001J5', 'Second ticket', 'open', 'ticket', 'P2', ('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', ('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 ( 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 ) VALUES
('workspace-test', '00000000001J2', 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', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'), ('workspace-test', '00000000001J5', -1, '00000000001J5:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body'),
('workspace-test', '00000000001J6', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'); ('workspace-test', '00000000001J6', -1, '00000000001J6:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body');
"#, "#,
) )
.unwrap(); .unwrap();
rusqlite::Connection::open(&db_path) rusqlite::Connection::open(&db_path)
.unwrap() .unwrap()
.execute( .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![ rusqlite::params![
"workspace-test", "workspace-test",
"00000000001J2", "00000000001J2",
@@ -4044,11 +4103,15 @@ VALUES ('workspace-test', 'ticket', 4);
.execute_batch( .execute_batch(
r#" r#"
INSERT INTO typed_tickets ( 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 workflow_state, workflow_state_explicit
) VALUES ) VALUES
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', '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); ('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 ( INSERT INTO workspace_resource_keys (
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
) VALUES ) VALUES
+576 -78
View File
@@ -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_orchestration_plan_record(service.clone()))
.merge(server_api::server_api_axum::ticket_get(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_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_show(service.clone()))
.merge(server_api::server_api_axum::ticket_assignment_list( .merge(server_api::server_api_axum::ticket_assignment_list(
service.clone(), service.clone(),
@@ -10286,12 +10295,19 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::CreateTicketRecordRequest, request: server_api::CreateTicketRecordRequest,
) -> std::result::Result<server_api::TicketRecordRef, server_api::RepositoryApiError> { ) -> std::result::Result<server_api::TicketRecordRef, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context( 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 }, 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 { let Json(response) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::TicketRef(ticket) => Some(ticket), _ => None, TicketBackendOperationResult::TicketRef(ticket) => Some(ticket),
}).map_err(ApiError::into_repository_api_error)?; _ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecordRef(response)) Ok(server_api::TicketRecordRef(response))
} }
@@ -10432,15 +10448,54 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::EditTicketRecordItemRequest, request: server_api::EditTicketRecordItemRequest,
) -> std::result::Result<server_api::TicketRecord, server_api::RepositoryApiError> { ) -> std::result::Result<server_api::TicketRecord, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context( let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context), self.workspace_api()?,
TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(id), edit: request.0 }, &workspace_id,
).await.map_err(ApiError::into_repository_api_error)?; 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 { let Json(response) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket), _ => None, TicketBackendOperationResult::Ticket(ticket) => Some(ticket),
}).map_err(ApiError::into_repository_api_error)?; _ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecord(response)) 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<server_api::TicketPostSnapshots, server_api::RepositoryApiError> {
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( async fn ticket_dependency_check(
&self, &self,
context: server_api::ServerRequestContext, context: server_api::ServerRequestContext,
@@ -10468,9 +10523,17 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::TicketThreadEventRequest, request: server_api::TicketThreadEventRequest,
) -> std::result::Result<(), server_api::RepositoryApiError> { ) -> std::result::Result<(), server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context( let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context), self.workspace_api()?,
TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(id), event: request.0 }, &workspace_id,
).await.map_err(ApiError::into_repository_api_error)?; 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)?; ticket_rest_unit(result).map_err(ApiError::into_repository_api_error)?;
Ok(()) Ok(())
} }
@@ -10948,18 +11011,34 @@ impl server_api::ServerApi for ServerApiContractService {
} }
async fn ticket_post_get( 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<server_api::TicketEventDetail, server_api::RepositoryApiError> { ) -> std::result::Result<server_api::TicketEventDetail, server_api::RepositoryApiError> {
scoped_get_ticket_post(self.workspace_api()?.clone(), workspace_id, id, event_ref) 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( async fn ticket_post_edit(
&self, context: server_api::ServerRequestContext, workspace_id: String, id: String, &self,
event_ref: String, request: server_api::TicketPostEditRequest, context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
event_ref: String,
request: server_api::TicketPostEditRequest,
) -> std::result::Result<TicketDetail, server_api::RepositoryApiError> { ) -> std::result::Result<TicketDetail, server_api::RepositoryApiError> {
let Json(detail) = scoped_edit_ticket_post(self.workspace_api()?.clone(), context, let Json(detail) = scoped_edit_ticket_post(
workspace_id, id, event_ref, request).await.map_err(ApiError::into_repository_api_error)?; self.workspace_api()?.clone(),
context,
workspace_id,
id,
event_ref,
request,
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(detail) Ok(detail)
} }
@@ -15435,24 +15514,52 @@ async fn scoped_edit_ticket_item(
) -> ApiResult<Json<TicketDetail>> { ) -> ApiResult<Json<TicketDetail>> {
let targets = request.target.map(|target| match target { let targets = request.target.map(|target| match target {
server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set { server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set {
targets: targets.into_iter().map(|target| ticket::TicketTarget { targets: targets
repository_key: target.repository_key, ref_selector: target.ref_selector, access: target.access, .into_iter()
}).collect(), .map(|target| ticket::TicketTarget {
repository_key: target.repository_key,
ref_selector: target.ref_selector,
access: target.access,
})
.collect(),
}, },
server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear, server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear,
}); });
let body_replacement = match (request.old_string, request.new_string) { 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, (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, execute_ticket_rest_operation_with_context(
contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context), &api,
TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(path.id.clone()), edit: TicketItemEdit { &path.workspace_id,
title: request.title, body: request.body.map(MarkdownText::new), body_replacement, contract_request_headers(context).map_err(|error| Error::Config(error.message))?,
targets, author: request.author, Some(context),
} }).await?; TicketBackendOperation::EditItem {
api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await 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( 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::Comment => TicketEventKind::Comment,
server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan, server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan,
server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision, 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); let mut event = NewTicketEvent::new(role, request.body);
event.author = request.author; event.author = request.author;
execute_ticket_rest_operation_with_context(&api, &path.workspace_id, execute_ticket_rest_operation_with_context(
contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context), &api,
TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(path.id.clone()), event }).await?; &path.workspace_id,
api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await 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; const TICKET_EDITABLE_POST_BODY_LIMIT: usize = 1024 * 1024;
@@ -15484,67 +15604,106 @@ fn authenticated_ticket_post_author(
if let Some(source) = source { if let Some(source) = source {
return Ok(format!("worker:{}/{}", source.runtime_id, source.worker_id)); return Ok(format!("worker:{}/{}", source.runtime_id, source.worker_id));
} }
let actor = context.actor.as_ref().ok_or_else(|| Error::WorkspacePermissionDenied( let actor = context.actor.as_ref().ok_or_else(|| {
"Ticket posts require an authenticated actor".into()))?; Error::WorkspacePermissionDenied("Ticket posts require an authenticated actor".into())
})?;
let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id)) let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id))
.map_err(|error| Error::Config(format!("failed to encode post actor: {error}")))?; .map_err(|error| Error::Config(format!("failed to encode post actor: {error}")))?;
Ok(format!("workspace-user:{identity}")) Ok(format!("workspace-user:{identity}"))
} }
async fn scoped_get_ticket_post( 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<server_api::TicketEventDetail> { ) -> ApiResult<server_api::TicketEventDetail> {
api.host_executors.host()?.run(move || { api.host_executors
.host()?
.run(move || {
validate_workspace_scope(&api, &workspace_id)?; validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &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 ticket = browser_ticket_backend(&api)?
let (sequence, post) = ticket.events.iter().enumerate().find(|(_, post)| post.event_ref == event_ref) .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)))?; .ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref)))?;
if post.body.as_str().len() > TICKET_EDITABLE_POST_BODY_LIMIT { 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")); 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); let mut detail = crate::authority::ticket_event_detail(sequence, post);
detail.body = Some(post.body.as_str().to_string()); detail.body = Some(post.body.as_str().to_string());
detail.body_truncated = false; detail.body_truncated = false;
Ok(detail) Ok(detail)
}).await })
.await
} }
async fn scoped_edit_ticket_post( async fn scoped_edit_ticket_post(
api: WorkspaceApi, context: server_api::ServerRequestContext, api: WorkspaceApi,
workspace_id: String, id: String, event_ref: String, request: server_api::TicketPostEditRequest, context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
event_ref: String,
request: server_api::TicketPostEditRequest,
) -> ApiResult<Json<TicketDetail>> { ) -> ApiResult<Json<TicketDetail>> {
let headers = contract_request_headers(&context).map_err(|error| Error::Config(error.message))?; let headers =
let source = authenticated_ticket_worker_source(&headers, &context)?; contract_request_headers(&context).map_err(|error| Error::Config(error.message))?;
let author = authenticated_ticket_post_author(&context, source.as_ref())?;
if request.body.len() > TICKET_EDITABLE_POST_BODY_LIMIT { 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 || { api.host_executors
.host()?
.run(move || {
validate_workspace_scope(&api, &workspace_id)?; validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?; let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
let backend = browser_ticket_backend(&api)?; let backend = browser_ticket_backend(&api)?;
let observed = backend.show(TicketIdOrSlug::Id(id.clone())).map_err(Error::from)?; let observed = backend
let post = observed.events.iter().find(|post| post.event_ref == event_ref) .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())))?; .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 { if crate::authority::ticket_post_body_digest(post.body.as_str())
return Err(Error::Ticket(ticket::TicketError::Conflict("stale Ticket post body digest".into())).into()); != 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. // 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 { let expected_body = post.body.clone();
event_ref, expected_body: post.body.clone(), body: MarkdownText::new(request.body), author, execute_ticket_rest_operation_on_host(
}).map_err(Error::from)?; &api,
notify_ticket_recipients(&api, &workspace_id, &id, previous.meta.workflow_state.as_str(), &workspace_id,
current.meta.workflow_state.as_str(), source.clone()); headers,
if is_initial && let Some(source) = source { Some(&context),
schedule_ticket_item_check(&api, &current, TicketItemCheckEdit::Edit { TicketBackendOperation::EditPost {
title_changed: false, body_changed: true, previous_title: None, id: TicketIdOrSlug::Id(id.clone()),
previous_body: Some(previous.document.body.as_str().to_string()), body_replacement: None, edit: ticket::TicketPostEdit {
}, source); event_ref,
} expected_body,
body: MarkdownText::new(request.body),
author: String::new(),
},
},
)?;
browser_ticket_detail(&api, &id) browser_ticket_detail(&api, &id)
}).await })
.await
} }
async fn scoped_mark_ticket_ready_from_browser( async fn scoped_mark_ticket_ready_from_browser(
@@ -15860,13 +16019,19 @@ fn execute_ticket_rest_operation_on_host(
} else { } else {
verified_source.or(hinted_source) verified_source.or(hinted_source)
}; };
if matches!(operation, TicketBackendOperation::Create { .. } | TicketBackendOperation::EditItem { .. } | TicketBackendOperation::AddEvent { .. }) if matches!(
&& let Some(context) = context operation,
TicketBackendOperation::Create { .. }
| TicketBackendOperation::EditItem { .. }
| TicketBackendOperation::EditPost { .. }
| TicketBackendOperation::AddEvent { .. }
) && let Some(context) = context
{ {
let author = authenticated_ticket_post_author(context, source.as_ref())?; let author = authenticated_ticket_post_author(context, source.as_ref())?;
match &mut operation { match &mut operation {
TicketBackendOperation::Create { input } => input.author = Some(author), TicketBackendOperation::Create { input } => input.author = Some(author),
TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author), TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author),
TicketBackendOperation::EditPost { edit, .. } => edit.author = author,
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author), TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
_ => unreachable!(), _ => unreachable!(),
} }
@@ -15987,6 +16152,35 @@ fn execute_ticket_rest_operation_on_host(
} }
(TicketBackendOperationResult::Ticket(ticket), None) (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 => ( operation => (
execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?, execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?,
None, None,
@@ -16005,6 +16199,16 @@ fn execute_ticket_rest_operation_on_host(
source.clone(), source.clone(),
); );
} }
} else if let TicketBackendOperationResult::TicketSnapshots { previous, current } = &result
{
notify_ticket_recipients(
api,
workspace_id,
&current.meta.id,
previous.meta.workflow_state.as_str(),
current.meta.workflow_state.as_str(),
source.clone(),
);
} else if let Some(target) = target } else if let Some(target) = target
&& let Ok(ticket) = backend.show(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 { if let Some((source_worker, edit)) = ticket_item_check {
let checked_ticket = created_ticket.or_else(|| match &result { let checked_ticket = created_ticket.or_else(|| match &result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket.clone()), TicketBackendOperationResult::Ticket(ticket) => Some(ticket.clone()),
TicketBackendOperationResult::TicketSnapshots { current, .. } => Some(current.clone()),
_ => None, _ => None,
}); });
if let Some(ticket) = checked_ticket { if let Some(ticket) = checked_ticket {
@@ -18310,6 +18515,7 @@ type WorkerMutationSource = RuntimeWorkerRef;
fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketIdOrSlug> { fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketIdOrSlug> {
match operation { match operation {
TicketBackendOperation::EditItem { id, .. } TicketBackendOperation::EditItem { id, .. }
| TicketBackendOperation::EditPost { id, .. }
| TicketBackendOperation::AddEvent { id, .. } | TicketBackendOperation::AddEvent { id, .. }
| TicketBackendOperation::AddStateChanged { id, .. } | TicketBackendOperation::AddStateChanged { id, .. }
| TicketBackendOperation::AddIntakeSummary { id, .. } | TicketBackendOperation::AddIntakeSummary { id, .. }
@@ -18349,6 +18555,7 @@ fn bind_worker_ticket_operation_source(
match operation { match operation {
TicketBackendOperation::Create { input } => input.author = Some(author), TicketBackendOperation::Create { input } => input.author = Some(author),
TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author), TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author),
TicketBackendOperation::EditPost { edit, .. } => edit.author = author,
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author), TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
TicketBackendOperation::AddStateChanged { change, .. } => change.author = Some(author), TicketBackendOperation::AddStateChanged { change, .. } => change.author = Some(author),
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author), TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
@@ -18373,6 +18580,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
match operation { match operation {
TicketBackendOperation::Create { .. } => "create", TicketBackendOperation::Create { .. } => "create",
TicketBackendOperation::EditItem { .. } => "edit_item", TicketBackendOperation::EditItem { .. } => "edit_item",
TicketBackendOperation::EditPost { .. } => "edit_post",
TicketBackendOperation::AddEvent { .. } => "add_event", TicketBackendOperation::AddEvent { .. } => "add_event",
TicketBackendOperation::AddStateChanged { .. } => "add_state_changed", TicketBackendOperation::AddStateChanged { .. } => "add_state_changed",
TicketBackendOperation::UpdateCase { .. } => "case_update", TicketBackendOperation::UpdateCase { .. } => "case_update",
@@ -47445,7 +47653,13 @@ mod tests {
.unwrap(); .unwrap();
let Json(_) = scoped_edit_ticket_item( let Json(_) = scoped_edit_ticket_item(
State(api.clone()), 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 { AxumPath(ScopedRecordPath {
workspace_id: TEST_WORKSPACE_ID.to_string(), workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_ref.id.clone(), id: ticket_ref.id.clone(),
@@ -52595,6 +52809,14 @@ mod tests {
axum::http::HeaderValue::from_str(&source_worker.worker.worker_id).unwrap(), axum::http::HeaderValue::from_str(&source_worker.worker.worker_id).unwrap(),
); );
let response = build_inner_router(api.clone()) 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( .oneshot(
Request::builder() Request::builder()
.method("POST") .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(&current)
);
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, &current).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, &current);
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(&current)
);
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(&current)
);
assert!(ticket_check_jobs_for_snapshot(&api, &current).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(&current)
);
} else {
assert_eq!(current.document.body, original.document.body);
assert_eq!(
ticket::ticket_content_digest(&latest),
ticket::ticket_content_digest(&current)
);
}
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] #[tokio::test]
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() { async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
@@ -53539,7 +54018,13 @@ mod tests {
let Json(edited) = scoped_edit_ticket_item( let Json(edited) = scoped_edit_ticket_item(
State(api.clone()), 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()), AxumPath(path()),
Json(server_api::BrowserEditTicketRequest { Json(server_api::BrowserEditTicketRequest {
title: Some("Browser Ticket API edited".to_string()), title: Some("Browser Ticket API edited".to_string()),
@@ -53577,7 +54062,13 @@ mod tests {
let Json(commented) = scoped_append_ticket_event( let Json(commented) = scoped_append_ticket_event(
State(api.clone()), 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()), AxumPath(path()),
Json(server_api::BrowserAppendTicketEventRequest { Json(server_api::BrowserAppendTicketEventRequest {
role: server_api::BrowserTicketThreadRole::Comment, role: server_api::BrowserTicketThreadRole::Comment,
@@ -55228,9 +55719,14 @@ mod tests {
crate::store::configure_sqlite(&conn).unwrap(); crate::store::configure_sqlite(&conn).unwrap();
conn.execute( conn.execute(
"INSERT INTO typed_tickets ( "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 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], rusqlite::params![api.config.workspace_id, ticket_id],
) )
.unwrap(); .unwrap();
@@ -60402,7 +60898,7 @@ mod tests {
{ {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await; 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 let response = app
.clone() .clone()
@@ -63348,11 +63844,13 @@ mod tests {
.execute_batch( .execute_batch(
r#" r#"
INSERT INTO typed_tickets ( 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 workflow_state, workflow_state_explicit
) VALUES ) VALUES
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', '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); ('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 ( INSERT INTO workspace_resource_keys (
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
) VALUES ) VALUES
+43 -13
View File
@@ -14004,6 +14004,7 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
let tx = conn.unchecked_transaction()?; let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx) ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?; .map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
assignment_delivery::install_post_inputs(&tx)?;
tx.execute( tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')", "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<()> { fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?; create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?; assignment_delivery::install(conn)?;
assignment_delivery::install_post_inputs(conn)?;
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| { ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
Error::Store(format!( Error::Store(format!(
"fresh Workspace Ticket schema verification failed: {error}" "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(()) Ok(())
} }
@@ -20146,16 +20168,18 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
let (_dir, path) = legacy_case_workspace(90); let (_dir, path) = legacy_case_workspace(90);
let conn = Connection::open(&path).unwrap(); let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).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) 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); VALUES('case-workspace','terminal','terminal','Terminal','closed','task','P2','','done',1);
DROP VIEW ticket_active_worker_assignments; 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); install_retained_ticket_tags8(&conn);
let _tags = seed_retained_classification(&conn, "case-workspace", "terminal"); let _tags = seed_retained_classification(&conn, "case-workspace", "terminal");
let tags_before = classification_rows(&conn); let tags_before = classification_rows(&conn);
let auth_before = fixture_auth_rows(&conn); let auth_before = fixture_auth_rows(&conn);
let error = migrate_ticket_cases_v90_to_v91(&conn).unwrap_err(); 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!(current_schema_version(&conn).unwrap(), 90);
assert_eq!(fixture_auth_rows(&conn), auth_before); assert_eq!(fixture_auth_rows(&conn), auth_before);
assert_eq!(classification_rows(&conn), tags_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'", [], FROM typed_tickets WHERE ticket_id='ticket'", [],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?; |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?;
if state == "closed" { 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 { } else {
assert_eq!((persisted.as_str(), events, assignments, operations), ("open",1,1,1)); assert_eq!((persisted.as_str(), events, assignments, operations), ("open",2,1,1));
let transition: (Option<String>,Option<String>,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)?)))?; let transition: (Option<String>,Option<String>,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!(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"); 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( conn.execute_batch(
r#" r#"
INSERT INTO typed_tickets ( 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 workflow_state, workflow_state_explicit
) VALUES ) VALUES
('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', '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-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-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 ( INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES ) VALUES
@@ -22032,9 +22058,11 @@ INSERT INTO workspace_signing_identities (
'2026-01-01', NULL, '2026-01-01' '2026-01-01', NULL, '2026-01-01'
); );
INSERT INTO typed_tickets ( 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 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 ( INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES ( ) VALUES (
@@ -22062,9 +22090,11 @@ INSERT INTO workspace_signing_identities (
'2026-01-01', NULL, '2026-01-01' '2026-01-01', NULL, '2026-01-01'
); );
INSERT INTO typed_tickets ( 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 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 ( INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES ( ) VALUES (
@@ -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"); let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice()); assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
assert_eq!(markers.len(),old_markers.len()+1); 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<String>,Option<String>,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)?)))?; let initial: (String,Option<String>,Option<String>,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())); 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::<rusqlite::Result<_>>()?; 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::<rusqlite::Result<_>>()?;
@@ -273,6 +274,7 @@ fn ticket_posts_fresh_and_migrated_schema_constraints_match() {
"typed_ticket_events", "typed_ticket_events",
"typed_ticket_event_attributes", "typed_ticket_event_attributes",
"typed_ticket_event_references", "typed_ticket_event_references",
"ticket_assignment_content_inputs",
] { ] {
for query in [ for query in [
format!( format!(
@@ -1298,10 +1298,14 @@ mod tests {
)?; )?;
conn.execute( conn.execute(
"INSERT INTO typed_tickets ( "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 workflow_state, workflow_state_explicit
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open', ) 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( conn.execute(
@@ -48,6 +48,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"ticket_assignment_delivery_acknowledgements", "ticket_assignment_delivery_acknowledgements",
"ticket_assignment_delivery_requests", "ticket_assignment_delivery_requests",
"ticket_assignment_deliveries", "ticket_assignment_deliveries",
"ticket_assignment_content_inputs",
"ticket_assignment_delivery_origins", "ticket_assignment_delivery_origins",
"ticket_assignment_operation_fences", "ticket_assignment_operation_fences",
"ticket_assignment_operations", "ticket_assignment_operations",
@@ -488,6 +489,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
("ticket_assignment_delivery_requests", "ticket_assignment_delivery_request_no_delete"), ("ticket_assignment_delivery_requests", "ticket_assignment_delivery_request_no_delete"),
("ticket_assignment_delivery_acknowledgements", "ticket_assignment_delivery_ack_no_delete"), ("ticket_assignment_delivery_acknowledgements", "ticket_assignment_delivery_ack_no_delete"),
("ticket_assignment_delivery_origins", "ticket_assignment_delivery_origin_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"), ("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_history", "typed_ticket_assignment_tracking_history_no_delete"),
("typed_ticket_assignment_tracking_operations", "typed_ticket_assignment_tracking_operations_no_delete"), ("typed_ticket_assignment_tracking_operations", "typed_ticket_assignment_tracking_operations_no_delete"),
@@ -1016,6 +1018,15 @@ mod tests {
false, false,
) )
.unwrap(); .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 // Seed domain handling evidence under a transaction. This fixture
// exercises owner purge, not singleton authentication or delivery. // exercises owner purge, not singleton authentication or delivery.
store store
@@ -1091,7 +1102,7 @@ mod tests {
} }
let assert_immutable = |workspace: &str| { let assert_immutable = |workspace: &str| {
store.with_conn(|conn| { 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!("DELETE FROM {table} WHERE workspace_id=?1"), [workspace]).is_err());
assert!(conn.execute(&format!("UPDATE {table} SET ticket_id=ticket_id WHERE workspace_id=?1"), [workspace]).is_err()); assert!(conn.execute(&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!(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;")?; conn.execute_batch("DROP TRIGGER reject_workspace_purge;")?;
Ok(()) Ok(())
}) })
@@ -1202,6 +1214,8 @@ mod tests {
assert_immutable("workspace-b"); assert_immutable("workspace-b");
store store
.with_conn(|conn| { .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) { for (table, before) in tables.iter().zip(&other_before) {
let count: i64 = conn.query_row( let count: i64 = conn.query_row(
&format!( &format!(
+155 -1
View File
@@ -12941,6 +12941,30 @@
}, },
"type": "object" "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": { "TicketPostEditRequest": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -14042,6 +14066,19 @@
], ],
"type": "object" "type": "object"
}, },
"Tuple_of_Ticket_and_Ticket": {
"maxItems": 2,
"minItems": 2,
"prefixItems": [
{
"$ref": "#/components/schemas/Ticket"
},
{
"$ref": "#/components/schemas/Ticket"
}
],
"type": "array"
},
"UpdateRemoteRuntimeRequest": { "UpdateRemoteRuntimeRequest": {
"additionalProperties": false, "additionalProperties": false,
"properties": { "properties": {
@@ -17198,7 +17235,7 @@
"info": { "info": {
"title": "Yoi Server API", "title": "Yoi Server API",
"version": "0.1.0", "version": "0.1.0",
"x-yoi-source-digest": "sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214" "x-yoi-source-digest": "sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904"
}, },
"openapi": "3.1.0", "openapi": "3.1.0",
"paths": { "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}": { "/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}": {
"get": { "get": {
"operationId": "ticket_post_get", "operationId": "ticket_post_get",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit. // @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts // Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit. // @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit. // @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts // Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit. // @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // 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 // Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check // Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit. // @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json // 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 // Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts // 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 // Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -54,23 +54,6 @@ function reads(path: string, row: TicketAssignmentTrackingProjection) {
if (path.includes("/assignment-tracking/assignments")) { if (path.includes("/assignment-tracking/assignments")) {
return Response.json({ items: [row], next_assignment_id: null }); 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}`); 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 () => { 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 () => { 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 row = assignment("delegation-1", "done");
const sent: unknown[] = []; const sent: unknown[] = [];
const paths: string[] = [];
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => { vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
paths.push(String(input));
if (init?.method === "PUT") { if (init?.method === "PUT") {
sent.push(JSON.parse(String(init.body))); sent.push(JSON.parse(String(init.body)));
return Response.json({ 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).toHaveLength(1);
expect(sent[0]).toMatchObject({ expected_assignment_id: "delegation-1" }); expect(sent[0]).toMatchObject({ expected_assignment_id: "delegation-1" });
expect(view.queryByText("Retry exact operation")).toBeNull(); expect(view.queryByText("Retry exact operation")).toBeNull();
expect(view.container.textContent).toContain("Outcome unknown"); await waitFor(() =>
expect(view.container.textContent).toContain( expect(view.getByRole("alert").textContent).toContain(
"Outcome unknown — may already be accepted", "Operation saved; latest projection unavailable",
); )
expect(view.container.textContent).toContain(
"No work start records observed",
); );
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"); 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 () => { test("classification conflict keeps stable IDs and the original observed set across an exact retry", async () => {