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 {
fn edit_post_with_snapshots(
&self,
id: TicketIdOrSlug,
edit: ticket::TicketPostEdit,
) -> ticket::Result<(Ticket, Ticket)> {
let workspace_id = self.workspace_id.clone();
let id = ticket_reference(&id);
self.generated(move |client| async move {
client
.ticket_record_post_edit(workspace_id, id, server_api::EditTicketPostRequest(edit))
.await
.map(|response| response.0)
})
.map_err(ticket_client_error)
}
fn list_workspace_tags(
&self,
request: ticket::TagListRequest,
@@ -994,6 +1010,47 @@ mod tests {
(format!("http://{address}"), receiver, handle)
}
#[test]
fn post_edit_uses_native_patch_and_returns_server_snapshots_without_reread() {
let temp = tempfile::tempdir().unwrap();
let backend =
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
.unwrap();
let mut input = NewTicket::new("Post snapshots");
input.body = MarkdownText::new("Original requirements");
let reference = backend.create(input).unwrap();
let original = backend.show(reference.id.clone().into()).unwrap();
let edit = ticket::TicketPostEdit {
event_ref: original.events[0].event_ref.clone(),
expected_body: original.document.body.clone(),
body: MarkdownText::new("Changed requirements"),
author: "authenticated-editor".into(),
};
let expected = backend
.edit_post_with_snapshots(reference.id.into(), edit.clone())
.unwrap();
let body = serde_json::to_string(&expected).unwrap();
let (url, requests, server) = one_response_server("200 OK", &body);
let client =
BackendWorkspaceProductClient::new_with_access_token(url, "workspace-a", "token")
.unwrap();
let actual = TicketBackend::edit_post_with_snapshots(
&client,
TicketIdOrSlug::Query("T-123".into()),
edit.clone(),
)
.unwrap();
assert_eq!(actual, expected);
let request = requests.recv().unwrap();
assert!(request.starts_with("PATCH /api/w/workspace-a/tickets/T-123/posts HTTP/1.1\r\n"));
let (_, body) = request.split_once("\r\n\r\n").unwrap();
assert_eq!(
serde_json::from_str::<ticket::TicketPostEdit>(body).unwrap(),
edit
);
server.join().unwrap();
}
#[test]
fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
+12
View File
@@ -312,6 +312,8 @@ impl_openapi_schema!(
CreateTicketRelationRequest,
DefaultIntakeReadyBodyRequest,
EditTicketRecordItemRequest,
EditTicketPostRequest,
TicketPostSnapshots,
MergeEvent,
MergeRequestDetailResponse,
MergeRequestListQuery,
@@ -3085,6 +3087,14 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: EditTicketRecordItemRequest,
) -> Result<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)]
async fn ticket_dependency_check(
&self,
@@ -8072,6 +8082,8 @@ transparent_ticket_dto!(
);
transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket);
transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit);
transparent_ticket_dto!(EditTicketPostRequest, ticket::TicketPostEdit);
transparent_ticket_dto!(TicketPostSnapshots, (ticket::Ticket, ticket::Ticket));
transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent);
transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary);
transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation);
+2 -8
View File
@@ -599,14 +599,8 @@ fn independent_case_schema9_adds_only_tags_without_reapplying_judgments_or_recei
reopened.events[0]
.attributes
.retain(|key, _| matches!(key.as_str(), "event_id" | "event_sequence"));
// The frozen v9 copy has no persisted event_id attributes. Its published
// fallback was a zero-based ordinal, not the source v12 persisted identity.
for (ordinal, event) in reopened.events.iter_mut().skip(1).enumerate() {
event.event_ref = format!("sequence:{ordinal}");
event
.attributes
.insert("event_id".into(), event.event_ref.clone());
}
// The frozen SQLite loader published ticket_id:event_index identities.
// These remain identical after migration, without replacing them with display ordinals.
let path = temp.path().join("retained9.db");
let connection = Connection::open(&path).unwrap();
schema7(&connection);
@@ -37,6 +37,14 @@ struct ModelTicketQueryItem {
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)]
pub(super) struct ModelTicketDetail {
ticket: String,
@@ -45,6 +53,7 @@ pub(super) struct ModelTicketDetail {
body: String,
body_truncated: bool,
initial_post: ModelTicketEvent,
targets: Vec<ModelTicketTarget>,
created_by: Option<String>,
state: ticket::TicketState,
closure_operation_key: Option<String>,
@@ -351,6 +360,7 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
root.get("initial_post")
.ok_or("Ticket detail response is missing initial_post")?,
)?,
targets: typed_field(root, "targets")?,
created_by: optional_string(root, "created_by")?,
state: typed_field(root, "state")?,
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() {
for state in ["open", "closed"] {
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,
"state": state, "events": [post_fixture(false, "No Git needed")],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
@@ -898,7 +908,7 @@ mod tests {
});
let response = json!({
"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,
"closure_history": history, "legacy_handoff": legacy,
"events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
@@ -1049,7 +1059,7 @@ mod tests {
"content_digest": "ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"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",
"readiness": null,
"priority": "P2",
@@ -1153,7 +1163,7 @@ mod tests {
"content_digest": "current-ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"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",
"events": [],
"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 history = json!([{"operation_key":"close-1", "previous_closure_operation_key":null, "action":{"action":"close", "reason":"not_planned"}, "explanation":"Not proceeding", "references":[{"kind":"ticket", "target":"T-12"}], "author":"workspace-user:alice", "at":"2026-01-01T00:00:00Z"}]);
let detail = json!({
"resource_key":"T-739", "title":"Case", "body":"Requirements", "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,
"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."
}
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 {
let (previous, current) = result.unwrap();
assert_eq!(previous.meta.id, record.meta.id);
assert_eq!(current.meta.id, record.meta.id);
assert_eq!(previous.meta.id, "T-718");
assert_eq!(current.meta.id, "T-718");
} else {
assert!(result.unwrap_err().to_string().contains("409"));
}
@@ -2545,6 +2545,121 @@ mod tests {
}
}
fn target_detail_fixture(targets: Value) -> Value {
json!({
"resource_key": "T-739", "title": "Work location", "body": "Requirements",
"body_truncated": false, "content_digest": "observed-content", "targets": targets,
"initial_post": {
"sequence": 0, "event_ref": "initial", "is_initial": true,
"kind": "comment", "body": "Requirements", "body_truncated": false,
"body_digest": "a".repeat(64), "editable": true,
"author": null, "at": null, "edited_at": null, "edited_by": null
},
"state": "open", "closure_operation_key": null, "current_closure": null,
"legacy_handoff": null, "closure_history": [], "assignment_tracking": [],
"tags": [], "events": [], "linked_objectives": [], "assignments": [],
"implementation_reports": [], "merge_requests": []
})
}
#[tokio::test]
async fn orchestrator_show_and_native_read_preserve_ordered_targets() {
for targets in [
json!([]),
json!([
{"repository_key": "main", "ref_selector": "develop", "access": "read_write"},
{"repository_key": "docs", "ref_selector": null, "access": "read_only"},
{"repository_key": "archive", "ref_selector": "refs/tags/v1", "access": "read_only"}
]),
] {
let client = Arc::new(DecisionClient {
requests: Mutex::new(Vec::new()),
response: crate::worker::WorkspaceResponse {
status: 200,
body: target_detail_fixture(targets.clone()).to_string(),
},
});
let definitions =
enabled_ticket_definitions(client.clone(), TicketFeatureAccess::workflow());
let definition = definitions
.into_iter()
.find(|definition| definition().0.name == "ShowTicket")
.unwrap();
let (meta, tool) = definition();
assert!(meta.description.contains("targets"));
let output = tool
.execute(
r#"{"id":"T-739","event_limit":1}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(
ticket_tool_output_json(output)
.unwrap_or_else(|_| panic!("Ticket read returned invalid JSON"))["targets"],
targets
);
let native =
native_ticket_tools(client.clone(), TicketFeatureAccess::workflow()).unwrap();
let read = native
.into_iter()
.find(|tool| tool.projection.operation == "read")
.unwrap();
let output = read
.tool
.execute(
r#"{"id":"T-739","event_cursor":"10"}"#,
ToolExecutionContext::direct(),
)
.await
.unwrap();
assert_eq!(
ticket_tool_output_json(output)
.unwrap_or_else(|_| panic!("Ticket read returned invalid JSON"))["targets"],
targets
);
let requests = client.requests.lock().unwrap();
assert_eq!(requests.len(), 2);
assert!(
requests
.iter()
.all(|request| request.path == "/api/w/workspace/tickets/T-739/show")
);
}
}
#[tokio::test]
async fn show_rejects_missing_or_malformed_targets_instead_of_reporting_unassigned() {
for malformed in [
None,
Some(Value::Null),
Some(json!({})),
Some(json!([{"repository_key":"main","access":"write"}])),
] {
let mut detail = target_detail_fixture(json!([]));
if let Some(value) = malformed {
detail["targets"] = value;
} else {
detail.as_object_mut().unwrap().remove("targets");
}
let client = Arc::new(DecisionClient {
requests: Mutex::new(Vec::new()),
response: crate::worker::WorkspaceResponse {
status: 200,
body: detail.to_string(),
},
});
let (_, tool) =
workspace_ticket_read_definition(client, WorkspaceTicketReadKind::Show)();
let error = tool
.execute(r#"{"id":"T-739"}"#, ToolExecutionContext::direct())
.await
.unwrap_err();
assert!(error.to_string().contains("targets"), "{error}");
}
}
#[test]
fn query_schema_allows_only_open_and_closed_case_states() {
let client = decision_client(200, TicketState::Open);
@@ -139,7 +139,7 @@ async fn classification_reads_forward_id_filters_and_preserve_tag_observations()
assert_eq!(body[field], input[field]);
}
}
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
let detail = json!({"resource_key":"T-740", "content_digest":"content-1", "title":"Classify", "body":"Body", "body_truncated":false, "targets":[], "initial_post":{"sequence":0,"event_ref":"initial","is_initial":true,"kind":"comment","body":"Body","body_truncated":false,"body_digest":"a".repeat(64),"editable":true,"author":null,"at":null,"edited_at":null,"edited_by":null}, "state":"open", "closure_operation_key":null, "current_closure":null, "legacy_handoff":null, "closure_history":[], "assignment_tracking":[], "tags":[tag()], "events":[], "linked_objectives":[], "assignments":[], "implementation_reports":[], "merge_requests":[]});
let client = recording(detail);
let (_, tool) =
workspace_ticket_read_definition(client.clone(), WorkspaceTicketReadKind::Show)();
@@ -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(())
}
/// Additive schema94 composition; keep the applied schema93 install unchanged.
pub(super) fn install_post_inputs(conn: &Connection) -> Result<()> {
conn.execute_batch(include_str!("assignment_content_inputs.sql"))?;
Ok(())
}
const INPUTS_SQL: &str = r#"
WITH eligible AS (
SELECT a.*,o.input_event_id,o.after_ticket_event_index
@@ -152,6 +158,10 @@ WITH eligible AS (
AND e.kind NOT LIKE 'assignment_delivery%'
AND COALESCE(e.state_field,'') NOT IN ('case','routing','assignment_tracking')
AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>a.assignment_id)
UNION ALL
SELECT a.ticket_id,a.assignment_id,'ticket-edit:' || printf('%020d',e.input_sequence),a.tracking_json,e.after_ticket_event_index+1
FROM eligible a JOIN ticket_assignment_content_inputs e
ON e.workspace_id=a.workspace_id AND e.ticket_id=a.ticket_id AND e.assignment_id=a.assignment_id
)
SELECT i.ticket_id,i.assignment_id,i.input_event_id,
json_extract(i.tracking_json,'$.waiting_reason'),json_extract(i.tracking_json,'$.next_action'),d.payload_json
@@ -180,7 +190,9 @@ fn inputs_in_tx(
assignment_id: Option<&str>,
input_event_id: Option<&str>,
) -> Result<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 rows = statement.query_map(
params![workspace_id, limit as i64, assignment_id, input_event_id],
@@ -404,12 +416,15 @@ pub(super) fn save_operation_fence(
// Same input selection as delivery, plus immutable delivered history. Never
// SELECT payload/source/content into the public diagnostic projection.
const DIAGNOSTIC_INPUTS_SQL: &str = r#"
WITH inputs AS (
WITH raw_inputs AS (
SELECT o.input_event_id,-1 AS input_order FROM ticket_assignment_delivery_origins o
WHERE o.workspace_id=?1 AND o.assignment_id=?3
UNION
SELECT d.input_event_id,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END
FROM ticket_assignment_deliveries d WHERE d.workspace_id=?1 AND d.assignment_id=?3
SELECT d.input_event_id,COALESCE(e.after_ticket_event_index+1,CASE WHEN d.input_event_id LIKE 'ticket-event:%' THEN CAST(substr(d.input_event_id,14) AS INTEGER) ELSE -1 END)
FROM ticket_assignment_deliveries d
LEFT JOIN ticket_assignment_content_inputs e ON e.workspace_id=d.workspace_id AND e.assignment_id=d.assignment_id
AND d.input_event_id='ticket-edit:' || printf('%020d',e.input_sequence)
WHERE d.workspace_id=?1 AND d.assignment_id=?3
UNION
SELECT 'ticket-event:' || e.event_index,e.event_index
FROM ticket_assignment_delivery_origins o JOIN typed_ticket_events e ON e.workspace_id=o.workspace_id AND e.ticket_id=o.ticket_id
@@ -425,6 +440,11 @@ WITH inputs AS (
JOIN ticket_worker_assignments original ON original.workspace_id=o.workspace_id AND original.assignment_id=o.assignment_id
WHERE later.workspace_id=o.workspace_id AND later.ticket_id=o.ticket_id AND la.rowid>original.rowid),9223372036854775807)
AND NOT EXISTS(SELECT 1 FROM typed_ticket_event_attributes attr WHERE attr.workspace_id=e.workspace_id AND attr.ticket_id=e.ticket_id AND attr.event_index=e.event_index AND attr.key='orchestrator_assignment_id' AND attr.value<>?3)
UNION
SELECT 'ticket-edit:' || printf('%020d',input_sequence),after_ticket_event_index+1
FROM ticket_assignment_content_inputs WHERE workspace_id=?1 AND ticket_id=?2 AND assignment_id=?3
), inputs AS (
SELECT input_event_id,ROW_NUMBER() OVER (ORDER BY input_order,input_event_id) AS input_order FROM raw_inputs
)
"#;
@@ -6,7 +6,7 @@ fn delivery_store_external_event(
state_field: Option<&str>,
) {
store.with_conn(|conn| {
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,kind,author,at,state_field,body) VALUES('w','tracking',?1,?2,'user','2026-10-12T00:00:00Z',?3,'Answer arrived')",params![index,kind,state_field])?;
conn.execute("INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,author,at,state_field,body) VALUES('w','tracking',?1,'tracking:' || ?1,?2,'user','2026-10-12T00:00:00Z',?3,'Answer arrived')",params![index,kind,state_field])?;
Ok(())
}).unwrap();
}
@@ -651,7 +651,7 @@ fn assignment_delivery_schema92_upgrade_uses_reconciled_migration_evidence_not_l
);
store
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 93);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(
stored_fixture_rows(conn, "ticket_worker_assignments"),
assignments
@@ -1033,3 +1033,120 @@ async fn assignment_diagnostic_start_cursor_pages_existing_claims_without_input_
let wire = serde_json::to_string(&first).unwrap();
assert!(!wire.contains("tracking-binding") && !wire.contains("External resource"));
}
#[tokio::test]
async fn assignment_content_edits_are_atomic_durable_inputs_without_appending_posts() {
use ticket::TicketBackend;
let (directory, store) = tracking_store_fixture().await;
tracking_store_assign(&store, "a", "assign-a");
let source = tracking_store_source();
let initial = delivery_store_reserve_initial(&store, &source);
store.ack_ticket_assignment_delivery("w", &initial.delivery_id, &source).unwrap();
let path = directory.path().join("workspace.db");
let backend = ticket::SqliteTicketBackend::open_verified(&path, "w").unwrap();
let original = backend.show("tracking".into()).unwrap();
backend.edit_item("tracking".into(), ticket::TicketItemEdit {
title: Some("Changed title".into()), ..Default::default()
}).unwrap();
backend.edit_item("tracking".into(), ticket::TicketItemEdit {
title: Some("Changed title".into()), ..Default::default()
}).unwrap();
let mut latest = backend.show("tracking".into()).unwrap();
for body in ["Changed requirements", "Final requirements"] {
backend.edit_post("tracking".into(), ticket::TicketPostEdit {
event_ref: latest.events[0].event_ref.clone(),
expected_body: latest.document.body.clone(),
body: ticket::MarkdownText::new(body), author: "fixture-editor".into(),
}).unwrap();
latest = backend.show("tracking".into()).unwrap();
}
assert_eq!(latest.events.len(), original.events.len());
assert_eq!(latest.events[0].event_ref, original.events[0].event_ref);
backend.add_event("tracking".into(), ticket::NewTicketEvent::new(
ticket::TicketEventKind::Comment, "Discussion",
)).unwrap();
let comment = backend.show("tracking".into()).unwrap().events.last().unwrap().clone();
backend.edit_post("tracking".into(), ticket::TicketPostEdit {
event_ref: comment.event_ref, expected_body: comment.body,
body: ticket::MarkdownText::new("Edited discussion"), author: "fixture-editor".into(),
}).unwrap();
// Input insertion participates in the content write, including rollback.
store.with_conn_mut(|conn| {
let tx = conn.transaction_with_behavior(TransactionBehavior::Immediate)?;
tx.execute("UPDATE typed_tickets SET title='Rolled back' WHERE ticket_id='tracking'", [])?;
tx.rollback()?;
assert!(conn.execute("UPDATE ticket_assignment_content_inputs SET assignment_id='other'", []).is_err());
assert!(conn.execute("DELETE FROM ticket_assignment_content_inputs", []).is_err());
Ok(())
}).unwrap();
let pending = store.list_pending_ticket_assignment_deliveries("w", 100).unwrap();
assert_eq!(pending.len(), 3, "no-op and discussion edits are not new request inputs");
assert!(pending.iter().all(|input| input.input_event_id.starts_with("ticket-edit:")));
let mut cursor = None;
let mut diagnostics = Vec::new();
loop {
let page = store.page_ticket_assignment_diagnostics("w", "tracking", "a", cursor.as_deref(), None, 1).unwrap();
diagnostics.extend(page.inputs.into_iter().map(|input| input.input_event_id));
cursor = page.next_input_event_id;
if cursor.is_none() { break; }
}
assert_eq!(diagnostics, std::iter::once(initial.input_event_id.clone())
.chain(pending.iter().map(|input| input.input_event_id.clone())).collect::<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 dependency_check =
dependency_backend.dependency_check(TicketIdOrSlug::Id(id.to_string()))?;
let initial = ticket.events.iter().find(|event| event.is_initial)
let initial = ticket
.events
.iter()
.find(|event| event.is_initial)
.ok_or_else(|| Error::Store("Ticket initial post is missing".into()))?;
let initial_post = ticket_event_detail(0, initial);
let body = initial_post.body.clone().unwrap_or_default();
let body_truncated = initial_post.body_truncated;
let subsequent_events = ticket.events.iter().filter(|event| !event.is_initial).collect::<Vec<_>>();
let subsequent_events = ticket
.events
.iter()
.filter(|event| !event.is_initial)
.collect::<Vec<_>>();
let event_limit = request
.event_limit
.unwrap_or(TICKET_EVENT_LIMIT)
@@ -1787,11 +1794,21 @@ fn ticket_event_ref(sequence: usize, event: &TicketEvent) -> String {
pub(crate) fn ticket_post_body_digest(body: &str) -> String {
use sha2::{Digest, Sha256};
format!("{:x}", Sha256::digest(body.as_bytes()))
Sha256::digest(body.as_bytes())
.iter()
.map(|byte| format!("{byte:02x}"))
.collect()
}
pub(crate) fn ticket_event_detail(sequence: usize, event: &TicketEvent) -> TicketEventDetail {
let (body, body_truncated) = truncate_body(event.body.as_str(), if event.is_initial { DETAIL_BODY_LIMIT } else { TICKET_EVENT_BODY_LIMIT });
let (body, body_truncated) = truncate_body(
event.body.as_str(),
if event.is_initial {
DETAIL_BODY_LIMIT
} else {
TICKET_EVENT_BODY_LIMIT
},
);
TicketEventDetail {
is_initial: event.is_initial,
edited_at: event.edited_at.clone(),
@@ -1946,6 +1963,11 @@ pub(crate) fn merge_request_summary(
}
fn substantive_item_edit(event: &TicketEvent) -> bool {
// Initial-comment edits replace requirements without appending an item_edit event.
// Discussion-comment edits do not change the Ticket's requirement scope.
if event.is_initial && event.edited_at.is_some() {
return true;
}
if event.kind.as_str() != "item_edit" {
return false;
}
@@ -3041,6 +3063,10 @@ mod tests {
attributes.insert("changes".to_string(), changes.to_string());
}
TicketEvent {
event_ref: format!("event:{kind}:{at}"),
is_initial: false,
edited_at: None,
edited_by: None,
kind: ticket::TicketEventKind::Other(kind.to_string()),
author: Some("coder".to_string()),
at: Some(at.to_string()),
@@ -3056,6 +3082,40 @@ mod tests {
}
}
#[test]
fn edited_initial_post_marks_rescope_but_discussion_edits_do_not() {
let evidence = TicketEvidenceSummary {
missing: vec!["review_after_rescope".into()],
..Default::default()
};
let mut post = ticket_event("comment", "2026-10-13T00:00:00Z", None);
post.kind = ticket::TicketEventKind::Comment;
post.is_initial = true;
assert!(!ticket_attention_matches(
"open",
&evidence,
false,
&[post.clone()],
"stale_after_rescope"
));
post.edited_at = Some("2026-10-13T01:00:00Z".into());
assert!(ticket_attention_matches(
"open",
&evidence,
false,
&[post.clone()],
"stale_after_rescope"
));
post.is_initial = false;
assert!(!ticket_attention_matches(
"open",
&evidence,
false,
&[post],
"stale_after_rescope"
));
}
fn unavailable_source(
code: &str,
) -> std::result::Result<String, server_api::MergeRequestRefDiagnostic> {
@@ -3615,30 +3675,29 @@ mod tests {
.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
created_at, updated_at, workflow_state, workflow_state_explicit
) VALUES
('workspace-test', '00000000001J2', '00000000001J2', 'Read bridge', 'open', 'ticket', 'P2',
'Ticket body. Deep Ticket marker. This body is intentionally long enough for bounded projection tests.',
'2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'open', 1),
('workspace-test', '00000000001J5', '00000000001J5', 'Second ticket', 'open', 'ticket', 'P2',
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'open', 1),
'2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'open', 1),
('workspace-test', '00000000001J6', '00000000001J6', 'Third ticket', 'open', 'ticket', 'P2',
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'open', 1);
'2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'open', 1);
INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, author, at, heading, body
workspace_id, ticket_id, event_index, event_ref, kind, author, at, body
) VALUES
('workspace-test', '00000000001J2', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'),
('workspace-test', '00000000001J5', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created'),
('workspace-test', '00000000001J6', 0, 'create', 'test', '2026-01-01T00:00:00Z', 'Created', 'created');
('workspace-test', '00000000001J2', -1, '00000000001J2:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body'),
('workspace-test', '00000000001J5', -1, '00000000001J5:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body'),
('workspace-test', '00000000001J6', -1, '00000000001J6:-1', 'comment', 'test', '2026-01-01T00:00:00Z', 'Ticket body');
"#,
)
.unwrap();
rusqlite::Connection::open(&db_path)
.unwrap()
.execute(
"UPDATE typed_tickets SET body = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2",
"UPDATE typed_ticket_events SET body = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2 AND event_index = -1",
rusqlite::params![
"workspace-test",
"00000000001J2",
@@ -4044,11 +4103,15 @@ VALUES ('workspace-test', 'ticket', 4);
.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1),
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1);
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', 'open', 1),
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, event_ref, kind, body)
VALUES
('workspace-test', '00000000001J2', -1, '00000000001J2:-1', 'comment', ''),
('workspace-test', '00000000001J3', -1, '00000000001J3:-1', 'comment', '');
INSERT INTO workspace_resource_keys (
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
) VALUES
+592 -94
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_get(service.clone()))
.merge(server_api::server_api_axum::ticket_edit(service.clone()))
.merge(server_api::server_api_axum::ticket_post_get(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_post_edit(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_record_post_edit(
service.clone(),
))
.merge(server_api::server_api_axum::ticket_show(service.clone()))
.merge(server_api::server_api_axum::ticket_assignment_list(
service.clone(),
@@ -10286,12 +10295,19 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::CreateTicketRecordRequest,
) -> std::result::Result<server_api::TicketRecordRef, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context),
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::Create { input: request.0 },
).await.map_err(ApiError::into_repository_api_error)?;
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(response) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::TicketRef(ticket) => Some(ticket), _ => None,
}).map_err(ApiError::into_repository_api_error)?;
TicketBackendOperationResult::TicketRef(ticket) => Some(ticket),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecordRef(response))
}
@@ -10432,15 +10448,54 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::EditTicketRecordItemRequest,
) -> std::result::Result<server_api::TicketRecord, server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context),
TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(id), edit: request.0 },
).await.map_err(ApiError::into_repository_api_error)?;
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::EditItem {
id: TicketIdOrSlug::Query(id),
edit: request.0,
},
)
.await
.map_err(ApiError::into_repository_api_error)?;
let Json(response) = ticket_rest_result(result, |result| match result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket), _ => None,
}).map_err(ApiError::into_repository_api_error)?;
TicketBackendOperationResult::Ticket(ticket) => Some(ticket),
_ => None,
})
.map_err(ApiError::into_repository_api_error)?;
Ok(server_api::TicketRecord(response))
}
async fn ticket_record_post_edit(
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
request: server_api::EditTicketPostRequest,
) -> std::result::Result<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(
&self,
context: server_api::ServerRequestContext,
@@ -10468,9 +10523,17 @@ impl server_api::ServerApi for ServerApiContractService {
request: server_api::TicketThreadEventRequest,
) -> std::result::Result<(), server_api::RepositoryApiError> {
let result = execute_ticket_rest_operation_with_context(
self.workspace_api()?, &workspace_id, contract_request_headers(&context)?, Some(&context),
TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(id), event: request.0 },
).await.map_err(ApiError::into_repository_api_error)?;
self.workspace_api()?,
&workspace_id,
contract_request_headers(&context)?,
Some(&context),
TicketBackendOperation::AddEvent {
id: TicketIdOrSlug::Query(id),
event: request.0,
},
)
.await
.map_err(ApiError::into_repository_api_error)?;
ticket_rest_unit(result).map_err(ApiError::into_repository_api_error)?;
Ok(())
}
@@ -10948,18 +11011,34 @@ impl server_api::ServerApi for ServerApiContractService {
}
async fn ticket_post_get(
&self, workspace_id: String, id: String, event_ref: String,
&self,
workspace_id: String,
id: String,
event_ref: String,
) -> std::result::Result<server_api::TicketEventDetail, server_api::RepositoryApiError> {
scoped_get_ticket_post(self.workspace_api()?.clone(), workspace_id, id, event_ref)
.await.map_err(ApiError::into_repository_api_error)
.await
.map_err(ApiError::into_repository_api_error)
}
async fn ticket_post_edit(
&self, context: server_api::ServerRequestContext, workspace_id: String, id: String,
event_ref: String, request: server_api::TicketPostEditRequest,
&self,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
event_ref: String,
request: server_api::TicketPostEditRequest,
) -> std::result::Result<TicketDetail, server_api::RepositoryApiError> {
let Json(detail) = scoped_edit_ticket_post(self.workspace_api()?.clone(), context,
workspace_id, id, event_ref, request).await.map_err(ApiError::into_repository_api_error)?;
let Json(detail) = scoped_edit_ticket_post(
self.workspace_api()?.clone(),
context,
workspace_id,
id,
event_ref,
request,
)
.await
.map_err(ApiError::into_repository_api_error)?;
Ok(detail)
}
@@ -15435,24 +15514,52 @@ async fn scoped_edit_ticket_item(
) -> ApiResult<Json<TicketDetail>> {
let targets = request.target.map(|target| match target {
server_api::BrowserTicketTargetEdit::Set { targets } => TicketTargetsEdit::Set {
targets: targets.into_iter().map(|target| ticket::TicketTarget {
repository_key: target.repository_key, ref_selector: target.ref_selector, access: target.access,
}).collect(),
targets: targets
.into_iter()
.map(|target| ticket::TicketTarget {
repository_key: target.repository_key,
ref_selector: target.ref_selector,
access: target.access,
})
.collect(),
},
server_api::BrowserTicketTargetEdit::Clear => TicketTargetsEdit::Clear,
});
let body_replacement = match (request.old_string, request.new_string) {
(Some(old_string), Some(new_string)) => Some(TicketBodyReplacement { old_string, new_string, replace_all: request.replace_all }),
(Some(old_string), Some(new_string)) => Some(TicketBodyReplacement {
old_string,
new_string,
replace_all: request.replace_all,
}),
(None, None) => None,
_ => return Err(settings_bad_request("invalid_ticket_edit_replacement", "old_string and new_string must be provided together")),
_ => {
return Err(settings_bad_request(
"invalid_ticket_edit_replacement",
"old_string and new_string must be provided together",
));
}
};
execute_ticket_rest_operation_with_context(&api, &path.workspace_id,
contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context),
TicketBackendOperation::EditItem { id: TicketIdOrSlug::Query(path.id.clone()), edit: TicketItemEdit {
title: request.title, body: request.body.map(MarkdownText::new), body_replacement,
targets, author: request.author,
} }).await?;
api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await
execute_ticket_rest_operation_with_context(
&api,
&path.workspace_id,
contract_request_headers(context).map_err(|error| Error::Config(error.message))?,
Some(context),
TicketBackendOperation::EditItem {
id: TicketIdOrSlug::Query(path.id.clone()),
edit: TicketItemEdit {
title: request.title,
body: request.body.map(MarkdownText::new),
body_replacement,
targets,
author: request.author,
},
},
)
.await?;
api.host_executors
.host()?
.run(move || browser_ticket_detail(&api, &path.id))
.await
}
async fn scoped_append_ticket_event(
@@ -15465,14 +15572,27 @@ async fn scoped_append_ticket_event(
server_api::BrowserTicketThreadRole::Comment => TicketEventKind::Comment,
server_api::BrowserTicketThreadRole::Plan => TicketEventKind::Plan,
server_api::BrowserTicketThreadRole::Decision => TicketEventKind::Decision,
server_api::BrowserTicketThreadRole::ImplementationReport => TicketEventKind::ImplementationReport,
server_api::BrowserTicketThreadRole::ImplementationReport => {
TicketEventKind::ImplementationReport
}
};
let mut event = NewTicketEvent::new(role, request.body);
event.author = request.author;
execute_ticket_rest_operation_with_context(&api, &path.workspace_id,
contract_request_headers(context).map_err(|error| Error::Config(error.message))?, Some(context),
TicketBackendOperation::AddEvent { id: TicketIdOrSlug::Query(path.id.clone()), event }).await?;
api.host_executors.host()?.run(move || browser_ticket_detail(&api, &path.id)).await
execute_ticket_rest_operation_with_context(
&api,
&path.workspace_id,
contract_request_headers(context).map_err(|error| Error::Config(error.message))?,
Some(context),
TicketBackendOperation::AddEvent {
id: TicketIdOrSlug::Query(path.id.clone()),
event,
},
)
.await?;
api.host_executors
.host()?
.run(move || browser_ticket_detail(&api, &path.id))
.await
}
const TICKET_EDITABLE_POST_BODY_LIMIT: usize = 1024 * 1024;
@@ -15484,67 +15604,106 @@ fn authenticated_ticket_post_author(
if let Some(source) = source {
return Ok(format!("worker:{}/{}", source.runtime_id, source.worker_id));
}
let actor = context.actor.as_ref().ok_or_else(|| Error::WorkspacePermissionDenied(
"Ticket posts require an authenticated actor".into()))?;
let actor = context.actor.as_ref().ok_or_else(|| {
Error::WorkspacePermissionDenied("Ticket posts require an authenticated actor".into())
})?;
let identity = serde_json::to_string(&(&actor.account_id, &actor.user_id))
.map_err(|error| Error::Config(format!("failed to encode post actor: {error}")))?;
Ok(format!("workspace-user:{identity}"))
}
async fn scoped_get_ticket_post(
api: WorkspaceApi, workspace_id: String, id: String, event_ref: String,
api: WorkspaceApi,
workspace_id: String,
id: String,
event_ref: String,
) -> ApiResult<server_api::TicketEventDetail> {
api.host_executors.host()?.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
let ticket = browser_ticket_backend(&api)?.show(TicketIdOrSlug::Id(id)).map_err(Error::from)?;
let (sequence, post) = ticket.events.iter().enumerate().find(|(_, post)| post.event_ref == event_ref)
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref)))?;
if post.body.as_str().len() > TICKET_EDITABLE_POST_BODY_LIMIT {
return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the full-post read limit"));
}
let mut detail = crate::authority::ticket_event_detail(sequence, post);
detail.body = Some(post.body.as_str().to_string());
detail.body_truncated = false;
Ok(detail)
}).await
api.host_executors
.host()?
.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
let ticket = browser_ticket_backend(&api)?
.show(TicketIdOrSlug::Id(id))
.map_err(Error::from)?;
let (sequence, post) = ticket
.events
.iter()
.enumerate()
.find(|(_, post)| post.event_ref == event_ref)
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref)))?;
if post.body.as_str().len() > TICKET_EDITABLE_POST_BODY_LIMIT {
return Err(settings_bad_request(
"ticket_post_too_large",
"Ticket post exceeds the full-post read limit",
));
}
let mut detail = crate::authority::ticket_event_detail(sequence, post);
detail.body = Some(post.body.as_str().to_string());
detail.body_truncated = false;
Ok(detail)
})
.await
}
async fn scoped_edit_ticket_post(
api: WorkspaceApi, context: server_api::ServerRequestContext,
workspace_id: String, id: String, event_ref: String, request: server_api::TicketPostEditRequest,
api: WorkspaceApi,
context: server_api::ServerRequestContext,
workspace_id: String,
id: String,
event_ref: String,
request: server_api::TicketPostEditRequest,
) -> ApiResult<Json<TicketDetail>> {
let headers = contract_request_headers(&context).map_err(|error| Error::Config(error.message))?;
let source = authenticated_ticket_worker_source(&headers, &context)?;
let author = authenticated_ticket_post_author(&context, source.as_ref())?;
let headers =
contract_request_headers(&context).map_err(|error| Error::Config(error.message))?;
if request.body.len() > TICKET_EDITABLE_POST_BODY_LIMIT {
return Err(settings_bad_request("ticket_post_too_large", "Ticket post exceeds the edit limit"));
return Err(settings_bad_request(
"ticket_post_too_large",
"Ticket post exceeds the edit limit",
));
}
api.host_executors.host()?.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
let backend = browser_ticket_backend(&api)?;
let observed = backend.show(TicketIdOrSlug::Id(id.clone())).map_err(Error::from)?;
let post = observed.events.iter().find(|post| post.event_ref == event_ref)
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref.clone())))?;
if crate::authority::ticket_post_body_digest(post.body.as_str()) != request.expected_body_digest {
return Err(Error::Ticket(ticket::TicketError::Conflict("stale Ticket post body digest".into())).into());
}
let is_initial = post.is_initial;
// The digest is a transport observation; storage compares its exact body in the write transaction.
let (previous, current) = backend.edit_post_with_snapshots(TicketIdOrSlug::Id(id.clone()), ticket::TicketPostEdit {
event_ref, expected_body: post.body.clone(), body: MarkdownText::new(request.body), author,
}).map_err(Error::from)?;
notify_ticket_recipients(&api, &workspace_id, &id, previous.meta.workflow_state.as_str(),
current.meta.workflow_state.as_str(), source.clone());
if is_initial && let Some(source) = source {
schedule_ticket_item_check(&api, &current, TicketItemCheckEdit::Edit {
title_changed: false, body_changed: true, previous_title: None,
previous_body: Some(previous.document.body.as_str().to_string()), body_replacement: None,
}, source);
}
browser_ticket_detail(&api, &id)
}).await
api.host_executors
.host()?
.run(move || {
validate_workspace_scope(&api, &workspace_id)?;
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
let backend = browser_ticket_backend(&api)?;
let observed = backend
.show(TicketIdOrSlug::Id(id.clone()))
.map_err(Error::from)?;
let post = observed
.events
.iter()
.find(|post| post.event_ref == event_ref)
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref.clone())))?;
if crate::authority::ticket_post_body_digest(post.body.as_str())
!= request.expected_body_digest
{
return Err(Error::Ticket(ticket::TicketError::Conflict(
"stale Ticket post body digest".into(),
))
.into());
}
// The digest is a transport observation; storage compares its exact body in the write transaction.
let expected_body = post.body.clone();
execute_ticket_rest_operation_on_host(
&api,
&workspace_id,
headers,
Some(&context),
TicketBackendOperation::EditPost {
id: TicketIdOrSlug::Id(id.clone()),
edit: ticket::TicketPostEdit {
event_ref,
expected_body,
body: MarkdownText::new(request.body),
author: String::new(),
},
},
)?;
browser_ticket_detail(&api, &id)
})
.await
}
async fn scoped_mark_ticket_ready_from_browser(
@@ -15860,13 +16019,19 @@ fn execute_ticket_rest_operation_on_host(
} else {
verified_source.or(hinted_source)
};
if matches!(operation, TicketBackendOperation::Create { .. } | TicketBackendOperation::EditItem { .. } | TicketBackendOperation::AddEvent { .. })
&& let Some(context) = context
if matches!(
operation,
TicketBackendOperation::Create { .. }
| TicketBackendOperation::EditItem { .. }
| TicketBackendOperation::EditPost { .. }
| TicketBackendOperation::AddEvent { .. }
) && let Some(context) = context
{
let author = authenticated_ticket_post_author(context, source.as_ref())?;
match &mut operation {
TicketBackendOperation::Create { input } => input.author = Some(author),
TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author),
TicketBackendOperation::EditPost { edit, .. } => edit.author = author,
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
_ => unreachable!(),
}
@@ -15987,6 +16152,35 @@ fn execute_ticket_rest_operation_on_host(
}
(TicketBackendOperationResult::Ticket(ticket), None)
}
TicketBackendOperation::EditPost { id, edit } => {
let event_ref = edit.event_ref.clone();
let (previous, current) = backend
.edit_post_with_snapshots(id, edit)
.map_err(Error::from)?;
// Check only an initial post, using the transaction's actual preimage.
ticket_item_check = source.clone().and_then(|source| {
previous
.events
.iter()
.find(|post| post.event_ref == event_ref && post.is_initial)
.map(|_| {
(
source,
TicketItemCheckEdit::Edit {
title_changed: false,
body_changed: true,
previous_title: None,
previous_body: Some(previous.document.body.as_str().to_string()),
body_replacement: None,
},
)
})
});
(
TicketBackendOperationResult::TicketSnapshots { previous, current },
None,
)
}
operation => (
execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?,
None,
@@ -16005,6 +16199,16 @@ fn execute_ticket_rest_operation_on_host(
source.clone(),
);
}
} else if let TicketBackendOperationResult::TicketSnapshots { previous, current } = &result
{
notify_ticket_recipients(
api,
workspace_id,
&current.meta.id,
previous.meta.workflow_state.as_str(),
current.meta.workflow_state.as_str(),
source.clone(),
);
} else if let Some(target) = target
&& let Ok(ticket) = backend.show(target)
{
@@ -16021,6 +16225,7 @@ fn execute_ticket_rest_operation_on_host(
if let Some((source_worker, edit)) = ticket_item_check {
let checked_ticket = created_ticket.or_else(|| match &result {
TicketBackendOperationResult::Ticket(ticket) => Some(ticket.clone()),
TicketBackendOperationResult::TicketSnapshots { current, .. } => Some(current.clone()),
_ => None,
});
if let Some(ticket) = checked_ticket {
@@ -18310,6 +18515,7 @@ type WorkerMutationSource = RuntimeWorkerRef;
fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketIdOrSlug> {
match operation {
TicketBackendOperation::EditItem { id, .. }
| TicketBackendOperation::EditPost { id, .. }
| TicketBackendOperation::AddEvent { id, .. }
| TicketBackendOperation::AddStateChanged { id, .. }
| TicketBackendOperation::AddIntakeSummary { id, .. }
@@ -18349,6 +18555,7 @@ fn bind_worker_ticket_operation_source(
match operation {
TicketBackendOperation::Create { input } => input.author = Some(author),
TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author),
TicketBackendOperation::EditPost { edit, .. } => edit.author = author,
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
TicketBackendOperation::AddStateChanged { change, .. } => change.author = Some(author),
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
@@ -18373,6 +18580,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
match operation {
TicketBackendOperation::Create { .. } => "create",
TicketBackendOperation::EditItem { .. } => "edit_item",
TicketBackendOperation::EditPost { .. } => "edit_post",
TicketBackendOperation::AddEvent { .. } => "add_event",
TicketBackendOperation::AddStateChanged { .. } => "add_state_changed",
TicketBackendOperation::UpdateCase { .. } => "case_update",
@@ -47445,7 +47653,13 @@ mod tests {
.unwrap();
let Json(_) = scoped_edit_ticket_item(
State(api.clone()),
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
&server_request_context(
Some(test_browser_request_actor()),
None,
None,
None,
&HeaderMap::new(),
),
AxumPath(ScopedRecordPath {
workspace_id: TEST_WORKSPACE_ID.to_string(),
id: ticket_ref.id.clone(),
@@ -52595,6 +52809,14 @@ mod tests {
axum::http::HeaderValue::from_str(&source_worker.worker.worker_id).unwrap(),
);
let response = build_inner_router(api.clone())
.layer(Extension(
crate::worker_source::VerifiedRuntimeRequestSource {
authentication_binding_id: "fixture-binding".into(),
token_id_hash: None,
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(),
worker_id: Some(source_worker.worker.worker_id.clone()),
},
))
.oneshot(
Request::builder()
.method("POST")
@@ -53470,6 +53692,263 @@ mod tests {
);
}
#[tokio::test]
async fn ticket_post_native_route_binds_actor_and_returns_atomic_snapshots() {
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Native post edit");
input.body = MarkdownText::new("Original initial post");
input.author = Some("original-author".into());
let (reference, original) = backend.create_with_snapshot(input).unwrap();
let app = build_inner_router(api.clone()).layer(Extension(test_browser_request_actor()));
let uri = format!(
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts",
reference.resource_key.as_ref().unwrap()
);
let edit = json!({
"event_ref": original.events[0].event_ref,
"expected_body": original.document.body,
"body": "Changed initial post",
"author": "forged-editor"
});
let response = request_json(
app.clone(),
"PATCH",
&uri,
Some(edit.clone()),
StatusCode::OK,
)
.await;
let (previous, current): (ticket::Ticket, ticket::Ticket) =
serde_json::from_value(response).unwrap();
assert_eq!(previous, original);
assert_eq!(current, backend.show(reference.id.clone().into()).unwrap());
assert_eq!(current.document.body.as_str(), "Changed initial post");
assert_ne!(
ticket::ticket_content_digest(&previous),
ticket::ticket_content_digest(&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]
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
let dir = tempfile::tempdir().unwrap();
@@ -53539,7 +54018,13 @@ mod tests {
let Json(edited) = scoped_edit_ticket_item(
State(api.clone()),
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
&server_request_context(
Some(test_browser_request_actor()),
None,
None,
None,
&HeaderMap::new(),
),
AxumPath(path()),
Json(server_api::BrowserEditTicketRequest {
title: Some("Browser Ticket API edited".to_string()),
@@ -53577,7 +54062,13 @@ mod tests {
let Json(commented) = scoped_append_ticket_event(
State(api.clone()),
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
&server_request_context(
Some(test_browser_request_actor()),
None,
None,
None,
&HeaderMap::new(),
),
AxumPath(path()),
Json(server_api::BrowserAppendTicketEventRequest {
role: server_api::BrowserTicketThreadRole::Comment,
@@ -55228,9 +55719,14 @@ mod tests {
crate::store::configure_sqlite(&conn).unwrap();
conn.execute(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', '', 'open', 1)",
) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', 'open', 1)",
rusqlite::params![api.config.workspace_id, ticket_id],
)
.unwrap();
conn.execute(
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES(?1,?2,-1,?2 || ':-1','comment','')",
rusqlite::params![api.config.workspace_id, ticket_id],
)
.unwrap();
@@ -60402,7 +60898,7 @@ mod tests {
{
let dir = tempfile::tempdir().unwrap();
let api = test_api(dir.path()).await;
let app = build_inner_router(api);
let app = build_inner_router(api).layer(Extension(test_browser_request_actor()));
let response = app
.clone()
@@ -63348,11 +63844,13 @@ mod tests {
.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1),
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1);
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', 'open', 1),
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
SELECT workspace_id,ticket_id,-1,ticket_id || ':-1','comment','' FROM typed_tickets WHERE workspace_id='0192f0e8-4d84-7d6e-a000-000000000001';
INSERT INTO workspace_resource_keys (
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
) VALUES
+43 -13
View File
@@ -14004,6 +14004,7 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
let tx = conn.unchecked_transaction()?;
ticket::migrate_sqlite_ticket_schema_in_transaction(&tx)
.map_err(|error| Error::Store(format!("Ticket posts migration failed: {error}")))?;
assignment_delivery::install_post_inputs(&tx)?;
tx.execute(
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(94,'Canonical Ticket posts')",
[],
@@ -14025,6 +14026,7 @@ fn migrate_ticket_posts_v93_to_v94(conn: &Connection) -> Result<()> {
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
create_workspace_schema_structure(conn)?;
assignment_delivery::install(conn)?;
assignment_delivery::install_post_inputs(conn)?;
ticket::verify_sqlite_ticket_schema(conn).map_err(|error| {
Error::Store(format!(
"fresh Workspace Ticket schema verification failed: {error}"
@@ -14260,6 +14262,26 @@ fn verify_workspace_resource_constraints(conn: &Connection) -> Result<()> {
}
}
}
if current_schema_version(conn)? >= 94 {
for (kind, name) in [
("table", "ticket_assignment_content_inputs"),
("trigger", "ticket_assignment_content_input_no_update"),
("trigger", "ticket_assignment_content_input_no_delete"),
("trigger", "ticket_assignment_title_input"),
("trigger", "ticket_assignment_initial_post_input"),
] {
let present: bool = conn.query_row(
"SELECT EXISTS(SELECT 1 FROM sqlite_schema WHERE type=?1 AND name=?2)",
params![kind, name],
|row| row.get(0),
)?;
if !present {
return Err(Error::Store(format!(
"Assignment content input constraint {name} is missing"
)));
}
}
}
Ok(())
}
@@ -20146,16 +20168,18 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
let (_dir, path) = legacy_case_workspace(90);
let conn = Connection::open(&path).unwrap();
configure_sqlite(&conn).unwrap();
// Keep the collision table valid for dependent triggers during the shared
// Ticket table rebuild. Only Workspace phase two must fail on DROP VIEW.
conn.execute_batch("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit)
VALUES('case-workspace','terminal','terminal','Terminal','closed','task','P2','','done',1);
DROP VIEW ticket_active_worker_assignments;
CREATE TABLE ticket_active_worker_assignments(collision TEXT);").unwrap();
CREATE TABLE ticket_active_worker_assignments AS SELECT * FROM ticket_current_worker_assignments WHERE 0;").unwrap();
install_retained_ticket_tags8(&conn);
let _tags = seed_retained_classification(&conn, "case-workspace", "terminal");
let tags_before = classification_rows(&conn);
let auth_before = fixture_auth_rows(&conn);
let error = migrate_ticket_cases_v90_to_v91(&conn).unwrap_err();
assert!(error.to_string().contains("DROP TABLE"));
assert!(error.to_string().contains("DROP TABLE"), "{error}");
assert_eq!(current_schema_version(&conn).unwrap(), 90);
assert_eq!(fixture_auth_rows(&conn), auth_before);
assert_eq!(classification_rows(&conn), tags_before);
@@ -21142,10 +21166,10 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
FROM typed_tickets WHERE ticket_id='ticket'", [],
|row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)))?;
if state == "closed" {
assert_eq!((persisted.as_str(), events, assignments, operations), (state,0,0,0));
assert_eq!((persisted.as_str(), events, assignments, operations), (state,1,0,0));
} else {
assert_eq!((persisted.as_str(), events, assignments, operations), ("open",1,1,1));
let transition: (Option<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)?)))?;
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 WHERE kind='assignment'", [], |row| Ok((row.get(0)?,row.get(1)?,row.get(2)?)))?;
assert_eq!(transition, (None, None, "assignment".into()));
assert_eq!(conn.query_row("SELECT value FROM typed_ticket_event_attributes WHERE key='assignment_role'", [], |row| row.get::<_,String>(0))?, "worker");
}
@@ -21404,12 +21428,14 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
conn.execute_batch(
r#"
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES
('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', 'open', 'task', 'normal', '', 'open', 1),
('workspace-a', 'ticket-2', 'ticket-2', 'Ticket 2', 'open', 'task', 'normal', '', 'open', 1),
('workspace-a', 'ticket-3', 'ticket-3', 'Ticket 3', 'open', 'task', 'normal', '', 'open', 1);
('workspace-a', 'ticket-1', 'ticket-1', 'Ticket 1', 'open', 'task', 'normal', 'open', 1),
('workspace-a', 'ticket-2', 'ticket-2', 'Ticket 2', 'open', 'task', 'normal', 'open', 1),
('workspace-a', 'ticket-3', 'ticket-3', 'Ticket 3', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
SELECT workspace_id,ticket_id,-1,ticket_id || ':-1','comment','' FROM typed_tickets WHERE workspace_id='workspace-a';
INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES
@@ -22032,9 +22058,11 @@ INSERT INTO workspace_signing_identities (
'2026-01-01', NULL, '2026-01-01'
);
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', '', 'open', 1);
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'A', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
VALUES ('workspace-a','ticket-a',-1,'ticket-a:-1','comment','');
INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES (
@@ -22062,9 +22090,11 @@ INSERT INTO workspace_signing_identities (
'2026-01-01', NULL, '2026-01-01'
);
INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', '', 'open', 1);
) VALUES ('workspace-b', 'ticket-a', 'ticket-a-b', 'B', 'open', 'task', 'normal', 'open', 1);
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
VALUES ('workspace-b','ticket-a',-1,'ticket-a:-1','comment','');
INSERT INTO worker_registry (
workspace_id, runtime_id, worker_id, display_name, retention_state, created_at, updated_at
) VALUES (
@@ -84,6 +84,7 @@ fn ticket_posts_schema93_preserves_initial_body_creator_comments_and_archived_au
let markers = stored_fixture_rows(conn,"ticket_schema_migrations");
assert_eq!(&markers[..old_markers.len()],old_markers.as_slice());
assert_eq!(markers.len(),old_markers.len()+1);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs",[],|r|r.get::<_,i64>(0))?,0,"migration does not turn historical edits into new inputs");
let initial: (String,Option<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()));
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_event_attributes",
"typed_ticket_event_references",
"ticket_assignment_content_inputs",
] {
for query in [
format!(
@@ -1298,10 +1298,14 @@ mod tests {
)?;
conn.execute(
"INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workspace_id, ticket_id, slug, title, status, kind, priority,
workflow_state, workflow_state_explicit
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open',
'task', 'normal', '', 'open', 1)",
'task', 'normal', 'open', 1)",
[],
)?;
conn.execute(
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES('workspace-a','ticket-a',-1,'ticket-a:-1','comment','')",
[],
)?;
conn.execute(
@@ -48,6 +48,7 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"ticket_assignment_delivery_acknowledgements",
"ticket_assignment_delivery_requests",
"ticket_assignment_deliveries",
"ticket_assignment_content_inputs",
"ticket_assignment_delivery_origins",
"ticket_assignment_operation_fences",
"ticket_assignment_operations",
@@ -488,6 +489,7 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
("ticket_assignment_delivery_requests", "ticket_assignment_delivery_request_no_delete"),
("ticket_assignment_delivery_acknowledgements", "ticket_assignment_delivery_ack_no_delete"),
("ticket_assignment_delivery_origins", "ticket_assignment_delivery_origin_no_delete"),
("ticket_assignment_content_inputs", "ticket_assignment_content_input_no_delete"),
("ticket_assignment_operation_fences", "ticket_assignment_operation_fence_no_delete"),
("typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_history_no_delete"),
("typed_ticket_assignment_tracking_operations", "typed_ticket_assignment_tracking_operations_no_delete"),
@@ -1016,6 +1018,15 @@ mod tests {
false,
)
.unwrap();
let initial = backend
.edit_item(
initial.meta.id.clone().into(),
ticket::TicketItemEdit {
title: Some("Changed case evidence".into()),
..Default::default()
},
)
.unwrap();
// Seed domain handling evidence under a transaction. This fixture
// exercises owner purge, not singleton authentication or delivery.
store
@@ -1091,7 +1102,7 @@ mod tests {
}
let assert_immutable = |workspace: &str| {
store.with_conn(|conn| {
for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_worker_assignment_events", "typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_operations", "ticket_assignment_operations"] {
for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot", "ticket_worker_assignment_events", "typed_ticket_assignment_tracking_history", "typed_ticket_assignment_tracking_operations", "ticket_assignment_operations", "ticket_assignment_content_inputs"] {
assert!(conn.execute(&format!("DELETE FROM {table} WHERE workspace_id=?1"), [workspace]).is_err());
assert!(conn.execute(&format!("UPDATE {table} SET ticket_id=ticket_id WHERE workspace_id=?1"), [workspace]).is_err());
}
@@ -1176,6 +1187,7 @@ mod tests {
};
assert_eq!(count, expected, "rollback must restore {table}");
}
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs WHERE workspace_id='workspace-a'", [], |row| row.get::<_,i64>(0))?, 1, "rollback restores content inputs");
conn.execute_batch("DROP TRIGGER reject_workspace_purge;")?;
Ok(())
})
@@ -1202,6 +1214,8 @@ mod tests {
assert_immutable("workspace-b");
store
.with_conn(|conn| {
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs WHERE workspace_id='workspace-a'", [], |row| row.get::<_,i64>(0))?, 0);
assert_eq!(conn.query_row("SELECT COUNT(*) FROM ticket_assignment_content_inputs WHERE workspace_id='workspace-b'", [], |row| row.get::<_,i64>(0))?, 1);
for (table, before) in tables.iter().zip(&other_before) {
let count: i64 = conn.query_row(
&format!(
+155 -1
View File
@@ -12941,6 +12941,30 @@
},
"type": "object"
},
"TicketPostEdit": {
"description": "A compare-and-swap edit of a real comment post. The host supplies authenticated author.",
"properties": {
"author": {
"type": "string"
},
"body": {
"$ref": "#/components/schemas/MarkdownText"
},
"event_ref": {
"type": "string"
},
"expected_body": {
"$ref": "#/components/schemas/MarkdownText"
}
},
"required": [
"event_ref",
"expected_body",
"body",
"author"
],
"type": "object"
},
"TicketPostEditRequest": {
"additionalProperties": false,
"properties": {
@@ -14042,6 +14066,19 @@
],
"type": "object"
},
"Tuple_of_Ticket_and_Ticket": {
"maxItems": 2,
"minItems": 2,
"prefixItems": [
{
"$ref": "#/components/schemas/Ticket"
},
{
"$ref": "#/components/schemas/Ticket"
}
],
"type": "array"
},
"UpdateRemoteRuntimeRequest": {
"additionalProperties": false,
"properties": {
@@ -17198,7 +17235,7 @@
"info": {
"title": "Yoi Server API",
"version": "0.1.0",
"x-yoi-source-digest": "sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214"
"x-yoi-source-digest": "sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904"
},
"openapi": "3.1.0",
"paths": {
@@ -35988,6 +36025,123 @@
]
}
},
"/api/w/{workspace_id}/tickets/{id}/posts": {
"patch": {
"operationId": "ticket_record_post_edit",
"parameters": [
{
"explode": false,
"in": "path",
"name": "workspace_id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
},
{
"explode": false,
"in": "path",
"name": "id",
"required": true,
"schema": {
"$ref": "#/components/schemas/string"
},
"style": "simple"
}
],
"requestBody": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/TicketPostEdit"
}
}
},
"required": true
},
"responses": {
"200": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/Tuple_of_Ticket_and_Ticket"
}
}
},
"description": "Successful response"
},
"400": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"401": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"403": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"404": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"409": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
},
"500": {
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/RepositoryApiError"
}
}
},
"description": "Error response"
}
},
"security": [
{
"bearerAuth": []
},
{
"browserSession": []
}
]
}
},
"/api/w/{workspace_id}/tickets/{id}/posts/{event_ref}": {
"get": {
"operationId": "ticket_post_get",
@@ -1,6 +1,6 @@
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/companion-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214)
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
// Output: web/workspace/src/lib/generated/repository-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/runtime-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:1d6615147b8d7143484b9a2aaf163da6916fcabb7560c675c5b7bf4d94aa2214)
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/ticket-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
@@ -1,6 +1,6 @@
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
// Input: openapi/server-api.json
// Input contract: 0.1.0 (sha256:73f7d252fbedf2efbc7d36a36259c72850cfaaede4693009800fb1bec3f295cb)
// Input contract: 0.1.0 (sha256:334f540d268db97ec52ce67092f4d672a5757f11df3589ac54153cd595e95904)
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
@@ -54,23 +54,6 @@ function reads(path: string, row: TicketAssignmentTrackingProjection) {
if (path.includes("/assignment-tracking/assignments")) {
return Response.json({ items: [row], next_assignment_id: null });
}
if (path.includes("/assignment-diagnostics")) {
return Response.json({
assignment_id: row.assignment_id,
current: true,
actionable: true,
input_origin_known: true,
inputs: [{
input_event_id: "input",
delivery_id: "delivery",
notification: "request_persisted",
acceptance_outcome_unknown: true,
}],
starts: [],
next_input_event_id: null,
next_start_operation_id: null,
});
}
throw new Error(`Unexpected read ${path}`);
}
test("an answer response loss keeps exact assignment/event/key/body for retry even when observed responsibility changes", async () => {
@@ -126,9 +109,11 @@ test("an answer response loss keeps exact assignment/event/key/body for retry ev
test("a done reissue persists before a failed refresh and does not pretend delivery or execution succeeded", async () => {
const row = assignment("delegation-1", "done");
const sent: unknown[] = [];
const paths: string[] = [];
vi.stubGlobal(
"fetch",
vi.fn(async (input: RequestInfo | URL, init?: RequestInit) => {
paths.push(String(input));
if (init?.method === "PUT") {
sent.push(JSON.parse(String(init.body)));
return Response.json({
@@ -164,13 +149,14 @@ test("a done reissue persists before a failed refresh and does not pretend deliv
expect(sent).toHaveLength(1);
expect(sent[0]).toMatchObject({ expected_assignment_id: "delegation-1" });
expect(view.queryByText("Retry exact operation")).toBeNull();
expect(view.container.textContent).toContain("Outcome unknown");
expect(view.container.textContent).toContain(
"Outcome unknown — may already be accepted",
);
expect(view.container.textContent).toContain(
"No work start records observed",
await waitFor(() =>
expect(view.getByRole("alert").textContent).toContain(
"Operation saved; latest projection unavailable",
)
);
expect(view.container.querySelector(".ticket-delivery")).toBeNull();
expect(view.queryByText("Delivery and execution")).toBeNull();
expect(paths.some((path) => path.includes("assignment-diagnostics"))).toBe(false);
expect(view.container.textContent).toContain("Self-reported handling: done");
});
test("classification conflict keeps stable IDs and the original observed set across an exact retry", async () => {