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