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:
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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":[]
|
||||
});
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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, ¤t, TicketItemCheckEdit::Edit {
|
||||
title_changed: false, body_changed: true, previous_title: None,
|
||||
previous_body: Some(previous.document.body.as_str().to_string()), body_replacement: None,
|
||||
}, source);
|
||||
}
|
||||
browser_ticket_detail(&api, &id)
|
||||
}).await
|
||||
api.host_executors
|
||||
.host()?
|
||||
.run(move || {
|
||||
validate_workspace_scope(&api, &workspace_id)?;
|
||||
let id = resolve_workspace_ticket_reference(&api, &workspace_id, &id)?;
|
||||
let backend = browser_ticket_backend(&api)?;
|
||||
let observed = backend
|
||||
.show(TicketIdOrSlug::Id(id.clone()))
|
||||
.map_err(Error::from)?;
|
||||
let post = observed
|
||||
.events
|
||||
.iter()
|
||||
.find(|post| post.event_ref == event_ref)
|
||||
.ok_or_else(|| Error::Ticket(ticket::TicketError::NotFound(event_ref.clone())))?;
|
||||
if crate::authority::ticket_post_body_digest(post.body.as_str())
|
||||
!= request.expected_body_digest
|
||||
{
|
||||
return Err(Error::Ticket(ticket::TicketError::Conflict(
|
||||
"stale Ticket post body digest".into(),
|
||||
))
|
||||
.into());
|
||||
}
|
||||
// The digest is a transport observation; storage compares its exact body in the write transaction.
|
||||
let expected_body = post.body.clone();
|
||||
execute_ticket_rest_operation_on_host(
|
||||
&api,
|
||||
&workspace_id,
|
||||
headers,
|
||||
Some(&context),
|
||||
TicketBackendOperation::EditPost {
|
||||
id: TicketIdOrSlug::Id(id.clone()),
|
||||
edit: ticket::TicketPostEdit {
|
||||
event_ref,
|
||||
expected_body,
|
||||
body: MarkdownText::new(request.body),
|
||||
author: String::new(),
|
||||
},
|
||||
},
|
||||
)?;
|
||||
browser_ticket_detail(&api, &id)
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
async fn scoped_mark_ticket_ready_from_browser(
|
||||
@@ -15860,13 +16019,19 @@ fn execute_ticket_rest_operation_on_host(
|
||||
} else {
|
||||
verified_source.or(hinted_source)
|
||||
};
|
||||
if matches!(operation, TicketBackendOperation::Create { .. } | TicketBackendOperation::EditItem { .. } | TicketBackendOperation::AddEvent { .. })
|
||||
&& let Some(context) = context
|
||||
if matches!(
|
||||
operation,
|
||||
TicketBackendOperation::Create { .. }
|
||||
| TicketBackendOperation::EditItem { .. }
|
||||
| TicketBackendOperation::EditPost { .. }
|
||||
| TicketBackendOperation::AddEvent { .. }
|
||||
) && let Some(context) = context
|
||||
{
|
||||
let author = authenticated_ticket_post_author(context, source.as_ref())?;
|
||||
match &mut operation {
|
||||
TicketBackendOperation::Create { input } => input.author = Some(author),
|
||||
TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author),
|
||||
TicketBackendOperation::EditPost { edit, .. } => edit.author = author,
|
||||
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
|
||||
_ => unreachable!(),
|
||||
}
|
||||
@@ -15987,6 +16152,35 @@ fn execute_ticket_rest_operation_on_host(
|
||||
}
|
||||
(TicketBackendOperationResult::Ticket(ticket), None)
|
||||
}
|
||||
TicketBackendOperation::EditPost { id, edit } => {
|
||||
let event_ref = edit.event_ref.clone();
|
||||
let (previous, current) = backend
|
||||
.edit_post_with_snapshots(id, edit)
|
||||
.map_err(Error::from)?;
|
||||
// Check only an initial post, using the transaction's actual preimage.
|
||||
ticket_item_check = source.clone().and_then(|source| {
|
||||
previous
|
||||
.events
|
||||
.iter()
|
||||
.find(|post| post.event_ref == event_ref && post.is_initial)
|
||||
.map(|_| {
|
||||
(
|
||||
source,
|
||||
TicketItemCheckEdit::Edit {
|
||||
title_changed: false,
|
||||
body_changed: true,
|
||||
previous_title: None,
|
||||
previous_body: Some(previous.document.body.as_str().to_string()),
|
||||
body_replacement: None,
|
||||
},
|
||||
)
|
||||
})
|
||||
});
|
||||
(
|
||||
TicketBackendOperationResult::TicketSnapshots { previous, current },
|
||||
None,
|
||||
)
|
||||
}
|
||||
operation => (
|
||||
execute_ticket_backend_operation(&backend, operation).map_err(Error::from)?,
|
||||
None,
|
||||
@@ -16005,6 +16199,16 @@ fn execute_ticket_rest_operation_on_host(
|
||||
source.clone(),
|
||||
);
|
||||
}
|
||||
} else if let TicketBackendOperationResult::TicketSnapshots { previous, current } = &result
|
||||
{
|
||||
notify_ticket_recipients(
|
||||
api,
|
||||
workspace_id,
|
||||
¤t.meta.id,
|
||||
previous.meta.workflow_state.as_str(),
|
||||
current.meta.workflow_state.as_str(),
|
||||
source.clone(),
|
||||
);
|
||||
} else if let Some(target) = target
|
||||
&& let Ok(ticket) = backend.show(target)
|
||||
{
|
||||
@@ -16021,6 +16225,7 @@ fn execute_ticket_rest_operation_on_host(
|
||||
if let Some((source_worker, edit)) = ticket_item_check {
|
||||
let checked_ticket = created_ticket.or_else(|| match &result {
|
||||
TicketBackendOperationResult::Ticket(ticket) => Some(ticket.clone()),
|
||||
TicketBackendOperationResult::TicketSnapshots { current, .. } => Some(current.clone()),
|
||||
_ => None,
|
||||
});
|
||||
if let Some(ticket) = checked_ticket {
|
||||
@@ -18310,6 +18515,7 @@ type WorkerMutationSource = RuntimeWorkerRef;
|
||||
fn ticket_mutation_target(operation: &TicketBackendOperation) -> Option<&TicketIdOrSlug> {
|
||||
match operation {
|
||||
TicketBackendOperation::EditItem { id, .. }
|
||||
| TicketBackendOperation::EditPost { id, .. }
|
||||
| TicketBackendOperation::AddEvent { id, .. }
|
||||
| TicketBackendOperation::AddStateChanged { id, .. }
|
||||
| TicketBackendOperation::AddIntakeSummary { id, .. }
|
||||
@@ -18349,6 +18555,7 @@ fn bind_worker_ticket_operation_source(
|
||||
match operation {
|
||||
TicketBackendOperation::Create { input } => input.author = Some(author),
|
||||
TicketBackendOperation::EditItem { edit, .. } => edit.author = Some(author),
|
||||
TicketBackendOperation::EditPost { edit, .. } => edit.author = author,
|
||||
TicketBackendOperation::AddEvent { event, .. } => event.author = Some(author),
|
||||
TicketBackendOperation::AddStateChanged { change, .. } => change.author = Some(author),
|
||||
TicketBackendOperation::AddIntakeSummary { summary, .. } => summary.author = Some(author),
|
||||
@@ -18373,6 +18580,7 @@ fn ticket_mutation_operation_kind(operation: &TicketBackendOperation) -> &'stati
|
||||
match operation {
|
||||
TicketBackendOperation::Create { .. } => "create",
|
||||
TicketBackendOperation::EditItem { .. } => "edit_item",
|
||||
TicketBackendOperation::EditPost { .. } => "edit_post",
|
||||
TicketBackendOperation::AddEvent { .. } => "add_event",
|
||||
TicketBackendOperation::AddStateChanged { .. } => "add_state_changed",
|
||||
TicketBackendOperation::UpdateCase { .. } => "case_update",
|
||||
@@ -47445,7 +47653,13 @@ mod tests {
|
||||
.unwrap();
|
||||
let Json(_) = scoped_edit_ticket_item(
|
||||
State(api.clone()),
|
||||
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
|
||||
&server_request_context(
|
||||
Some(test_browser_request_actor()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&HeaderMap::new(),
|
||||
),
|
||||
AxumPath(ScopedRecordPath {
|
||||
workspace_id: TEST_WORKSPACE_ID.to_string(),
|
||||
id: ticket_ref.id.clone(),
|
||||
@@ -52595,6 +52809,14 @@ mod tests {
|
||||
axum::http::HeaderValue::from_str(&source_worker.worker.worker_id).unwrap(),
|
||||
);
|
||||
let response = build_inner_router(api.clone())
|
||||
.layer(Extension(
|
||||
crate::worker_source::VerifiedRuntimeRequestSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
token_id_hash: None,
|
||||
runtime_id: EMBEDDED_WORKER_RUNTIME_ID.into(),
|
||||
worker_id: Some(source_worker.worker.worker_id.clone()),
|
||||
},
|
||||
))
|
||||
.oneshot(
|
||||
Request::builder()
|
||||
.method("POST")
|
||||
@@ -53470,6 +53692,263 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_post_native_route_binds_actor_and_returns_atomic_snapshots() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut input = ticket::NewTicket::new("Native post edit");
|
||||
input.body = MarkdownText::new("Original initial post");
|
||||
input.author = Some("original-author".into());
|
||||
let (reference, original) = backend.create_with_snapshot(input).unwrap();
|
||||
let app = build_inner_router(api.clone()).layer(Extension(test_browser_request_actor()));
|
||||
let uri = format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts",
|
||||
reference.resource_key.as_ref().unwrap()
|
||||
);
|
||||
let edit = json!({
|
||||
"event_ref": original.events[0].event_ref,
|
||||
"expected_body": original.document.body,
|
||||
"body": "Changed initial post",
|
||||
"author": "forged-editor"
|
||||
});
|
||||
let response = request_json(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&uri,
|
||||
Some(edit.clone()),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let (previous, current): (ticket::Ticket, ticket::Ticket) =
|
||||
serde_json::from_value(response).unwrap();
|
||||
assert_eq!(previous, original);
|
||||
assert_eq!(current, backend.show(reference.id.clone().into()).unwrap());
|
||||
assert_eq!(current.document.body.as_str(), "Changed initial post");
|
||||
assert_ne!(
|
||||
ticket::ticket_content_digest(&previous),
|
||||
ticket::ticket_content_digest(¤t)
|
||||
);
|
||||
assert_eq!(current.events.len(), original.events.len());
|
||||
assert_eq!(current.events[0].author, original.events[0].author);
|
||||
assert_eq!(current.events[0].at, original.events[0].at);
|
||||
let context = server_request_context(
|
||||
Some(test_browser_request_actor()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&HeaderMap::new(),
|
||||
);
|
||||
let author = authenticated_ticket_post_author(&context, None).unwrap();
|
||||
assert_eq!(
|
||||
current.events[0].edited_by.as_deref(),
|
||||
Some(author.as_str())
|
||||
);
|
||||
assert!(current.events[0].edited_at.is_some());
|
||||
assert!(ticket_check_jobs_for_snapshot(&api, ¤t).is_empty());
|
||||
request_json(app, "PATCH", &uri, Some(edit), StatusCode::CONFLICT).await;
|
||||
assert_eq!(backend.show(reference.id.into()).unwrap(), current);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_post_native_worker_route_checks_only_initial_with_atomic_preimage() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let source = spawn_ticket_check_source(&api, "post-editor");
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut input = ticket::NewTicket::new("Worker post edit");
|
||||
input.body = MarkdownText::new("Initial requirements");
|
||||
input.author = Some("creator".into());
|
||||
let reference = backend.create(input).unwrap();
|
||||
let mut comment = NewTicketEvent::new(TicketEventKind::Comment, "Discussion");
|
||||
comment.author = Some("commenter".into());
|
||||
backend
|
||||
.add_event(reference.id.clone().into(), comment)
|
||||
.unwrap();
|
||||
let original = backend.show(reference.id.clone().into()).unwrap();
|
||||
// Below proof middleware, inject the verified Worker subject; no identity hints required.
|
||||
let app = build_inner_router(api.clone()).layer(Extension(
|
||||
crate::worker_source::VerifiedRuntimeRequestSource {
|
||||
authentication_binding_id: "fixture-binding".into(),
|
||||
token_id_hash: None,
|
||||
runtime_id: source.runtime_id.clone(),
|
||||
worker_id: Some(source.worker_id.clone()),
|
||||
},
|
||||
));
|
||||
let uri = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts", reference.id);
|
||||
let editor = format!("worker:{}/{}", source.runtime_id, source.worker_id);
|
||||
let mut latest = original.clone();
|
||||
for (index, body) in [
|
||||
(1, "Changed discussion"),
|
||||
(0, "Changed requirements"),
|
||||
(0, "Final requirements"),
|
||||
] {
|
||||
let edit = json!({ "event_ref": latest.events[index].event_ref,
|
||||
"expected_body": latest.events[index].body, "body": body, "author": "forged-editor" });
|
||||
let response = request_json(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&uri,
|
||||
Some(edit.clone()),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let (previous, current): (ticket::Ticket, ticket::Ticket) =
|
||||
serde_json::from_value(response).unwrap();
|
||||
assert_eq!(previous, latest);
|
||||
assert_eq!(
|
||||
current.events[index].edited_by.as_deref(),
|
||||
Some(editor.as_str())
|
||||
);
|
||||
assert_eq!(current.events[index].author, original.events[index].author);
|
||||
assert_eq!(current.events[index].at, original.events[index].at);
|
||||
assert_eq!(current.events.len(), original.events.len());
|
||||
if index == 0 {
|
||||
let jobs = ticket_check_jobs_for_snapshot(&api, ¤t);
|
||||
assert_eq!(jobs.len(), 1);
|
||||
let checked = ticket_item_checker::parse_input(&jobs[0].request.input).unwrap();
|
||||
assert_eq!(checked.body, current.document.body.as_str());
|
||||
assert_eq!(
|
||||
checked.content_digest,
|
||||
ticket_item_checker::content_digest(¤t)
|
||||
);
|
||||
assert!(matches!(checked.edit, TicketItemCheckEdit::Edit {
|
||||
title_changed: false, body_changed: true, previous_body: Some(ref body), ..
|
||||
} if body == previous.document.body.as_str()));
|
||||
} else {
|
||||
assert_eq!(
|
||||
ticket::ticket_content_digest(&previous),
|
||||
ticket::ticket_content_digest(¤t)
|
||||
);
|
||||
assert!(ticket_check_jobs_for_snapshot(&api, ¤t).is_empty());
|
||||
}
|
||||
request_json(app.clone(), "PATCH", &uri, Some(edit), StatusCode::CONFLICT).await;
|
||||
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current);
|
||||
latest = current;
|
||||
}
|
||||
tokio::time::timeout(std::time::Duration::from_secs(5), async {
|
||||
api.host_executors.host().unwrap().drain().await.unwrap();
|
||||
api.host_executors.cleanup().unwrap().drain().await.unwrap();
|
||||
api.worker_projection.shutdown_async().await;
|
||||
api.store.db_executor().drain().await.unwrap();
|
||||
})
|
||||
.await
|
||||
.expect("post fixture must drain accepted work");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_post_browser_routes_read_full_body_and_cas_initial_and_comment() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let mut input = ticket::NewTicket::new("Browser posts");
|
||||
input.body = MarkdownText::new("Initial post");
|
||||
input.author = Some("creator".into());
|
||||
let reference = backend.create(input).unwrap();
|
||||
let full_body = "長い comment\n".repeat(4000);
|
||||
let mut comment = NewTicketEvent::new(TicketEventKind::Comment, full_body.clone());
|
||||
comment.author = Some("commenter".into());
|
||||
backend
|
||||
.add_event(reference.id.clone().into(), comment)
|
||||
.unwrap();
|
||||
backend
|
||||
.add_event(
|
||||
reference.id.clone().into(),
|
||||
NewTicketEvent::new(TicketEventKind::Decision, "Immutable decision"),
|
||||
)
|
||||
.unwrap();
|
||||
let original = backend.show(reference.id.clone().into()).unwrap();
|
||||
let app = build_inner_router(api.clone()).layer(Extension(test_browser_request_actor()));
|
||||
let base = format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts",
|
||||
reference.resource_key.as_ref().unwrap()
|
||||
);
|
||||
let mut latest = original.clone();
|
||||
for (index, replacement) in [(1, "Changed discussion"), (0, "Changed initial")] {
|
||||
let uri = format!("{base}/{}", latest.events[index].event_ref);
|
||||
let read = request_json(app.clone(), "GET", &uri, None, StatusCode::OK).await;
|
||||
let post: server_api::TicketEventDetail = serde_json::from_value(read).unwrap();
|
||||
assert_eq!(
|
||||
post.body.as_deref(),
|
||||
Some(latest.events[index].body.as_str())
|
||||
);
|
||||
assert!(!post.body_truncated);
|
||||
assert!(post.editable);
|
||||
assert_eq!(
|
||||
post.body_digest,
|
||||
crate::authority::ticket_post_body_digest(latest.events[index].body.as_str())
|
||||
);
|
||||
let edit = json!({ "expected_body_digest": post.body_digest, "body": replacement });
|
||||
let response = request_json(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&uri,
|
||||
Some(edit.clone()),
|
||||
StatusCode::OK,
|
||||
)
|
||||
.await;
|
||||
let detail: TicketDetail = serde_json::from_value(response).unwrap();
|
||||
let current = backend.show(reference.id.clone().into()).unwrap();
|
||||
assert_eq!(detail.id, reference.id);
|
||||
assert_eq!(current.events.len(), original.events.len());
|
||||
assert_eq!(current.events[index].author, original.events[index].author);
|
||||
assert_eq!(current.events[index].at, original.events[index].at);
|
||||
assert!(
|
||||
current.events[index]
|
||||
.edited_by
|
||||
.as_ref()
|
||||
.unwrap()
|
||||
.starts_with("workspace-user:")
|
||||
);
|
||||
assert!(current.events[index].edited_at.is_some());
|
||||
assert_eq!(current.events[index].body.as_str(), replacement);
|
||||
if index == 0 {
|
||||
assert_eq!(current.document.body.as_str(), replacement);
|
||||
assert_ne!(
|
||||
ticket::ticket_content_digest(&latest),
|
||||
ticket::ticket_content_digest(¤t)
|
||||
);
|
||||
} else {
|
||||
assert_eq!(current.document.body, original.document.body);
|
||||
assert_eq!(
|
||||
ticket::ticket_content_digest(&latest),
|
||||
ticket::ticket_content_digest(¤t)
|
||||
);
|
||||
}
|
||||
request_json(app.clone(), "PATCH", &uri, Some(edit), StatusCode::CONFLICT).await;
|
||||
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), current);
|
||||
latest = current;
|
||||
}
|
||||
let system_uri = format!("{base}/{}", original.events[2].event_ref);
|
||||
let system = request_json(app.clone(), "GET", &system_uri, None, StatusCode::OK).await;
|
||||
assert_eq!(system["editable"], false);
|
||||
request_json(
|
||||
app.clone(),
|
||||
"PATCH",
|
||||
&system_uri,
|
||||
Some(json!({"expected_body_digest": system["body_digest"], "body": "forged"})),
|
||||
StatusCode::CONFLICT,
|
||||
)
|
||||
.await;
|
||||
request_json(
|
||||
app.clone(),
|
||||
"GET",
|
||||
&format!("{base}/missing"),
|
||||
None,
|
||||
StatusCode::NOT_FOUND,
|
||||
)
|
||||
.await;
|
||||
let other = backend
|
||||
.create(ticket::NewTicket::new("Other ticket"))
|
||||
.unwrap();
|
||||
let foreign = format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/posts/{}",
|
||||
other.id, original.events[0].event_ref
|
||||
);
|
||||
request_json(app, "GET", &foreign, None, StatusCode::NOT_FOUND).await;
|
||||
assert_eq!(backend.show(reference.id.into()).unwrap(), latest);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn ticket_browser_endpoints_mutate_typed_backend_and_return_thread() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
@@ -53539,7 +54018,13 @@ mod tests {
|
||||
|
||||
let Json(edited) = scoped_edit_ticket_item(
|
||||
State(api.clone()),
|
||||
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
|
||||
&server_request_context(
|
||||
Some(test_browser_request_actor()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&HeaderMap::new(),
|
||||
),
|
||||
AxumPath(path()),
|
||||
Json(server_api::BrowserEditTicketRequest {
|
||||
title: Some("Browser Ticket API edited".to_string()),
|
||||
@@ -53577,7 +54062,13 @@ mod tests {
|
||||
|
||||
let Json(commented) = scoped_append_ticket_event(
|
||||
State(api.clone()),
|
||||
&server_request_context(Some(test_browser_request_actor()), None, None, None, &HeaderMap::new()),
|
||||
&server_request_context(
|
||||
Some(test_browser_request_actor()),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&HeaderMap::new(),
|
||||
),
|
||||
AxumPath(path()),
|
||||
Json(server_api::BrowserAppendTicketEventRequest {
|
||||
role: server_api::BrowserTicketThreadRole::Comment,
|
||||
@@ -55228,9 +55719,14 @@ mod tests {
|
||||
crate::store::configure_sqlite(&conn).unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO typed_tickets (
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority,
|
||||
workflow_state, workflow_state_explicit
|
||||
) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', '', 'open', 1)",
|
||||
) VALUES (?1, ?2, ?2, ?2, 'open', 'task', 'normal', 'open', 1)",
|
||||
rusqlite::params![api.config.workspace_id, ticket_id],
|
||||
)
|
||||
.unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body) VALUES(?1,?2,-1,?2 || ':-1','comment','')",
|
||||
rusqlite::params![api.config.workspace_id, ticket_id],
|
||||
)
|
||||
.unwrap();
|
||||
@@ -60402,7 +60898,7 @@ mod tests {
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let app = build_inner_router(api);
|
||||
let app = build_inner_router(api).layer(Extension(test_browser_request_actor()));
|
||||
|
||||
let response = app
|
||||
.clone()
|
||||
@@ -63348,11 +63844,13 @@ mod tests {
|
||||
.execute_batch(
|
||||
r#"
|
||||
INSERT INTO typed_tickets (
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority, body,
|
||||
workspace_id, ticket_id, slug, title, status, kind, priority,
|
||||
workflow_state, workflow_state_explicit
|
||||
) VALUES
|
||||
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1),
|
||||
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1);
|
||||
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', 'open', 1),
|
||||
('0192f0e8-4d84-7d6e-a000-000000000001', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', 'open', 1);
|
||||
INSERT INTO typed_ticket_events(workspace_id,ticket_id,event_index,event_ref,kind,body)
|
||||
SELECT workspace_id,ticket_id,-1,ticket_id || ':-1','comment','' FROM typed_tickets WHERE workspace_id='0192f0e8-4d84-7d6e-a000-000000000001';
|
||||
INSERT INTO workspace_resource_keys (
|
||||
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
|
||||
) VALUES
|
||||
|
||||
@@ -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
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user