feat(ticket): decouple audited lifecycle decisions from MR integration
T-718: unify state, completion, close and reopen with explicit CAS and operation receipts; keep MR integration proofs independent. Queue only the explicitly selected Ticket and fence target edits against active Worker grants. Align REST, Worker/WIP, evidence projection and generated contracts. Validation: root cargo check; full ticket, merge-request, client, worker, yoi-workspace-server and server-api tests; server-api typescript feature; OpenAPI and six generated API checkers; Svelte check and 36 narrow Web tests; fmt and diff checks.
This commit is contained in:
@@ -155,14 +155,40 @@ impl BackendWorkspaceProductClient {
|
||||
})
|
||||
}
|
||||
|
||||
fn decision_from_state_change(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
) -> Result<ticket::TicketStateUpdate, BackendWorkspaceClientError> {
|
||||
let current = self.show_ticket(id)?;
|
||||
let parse = |state: &str| {
|
||||
ticket::TicketWorkflowState::parse(state).ok_or_else(|| {
|
||||
BackendWorkspaceClientError::InvalidTarget(format!("invalid Ticket state {state}"))
|
||||
})
|
||||
};
|
||||
Ok(ticket::TicketStateUpdate {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_item_revision: ticket::ticket_item_revision(¤t),
|
||||
expected_state: parse(&change.from)?,
|
||||
state: parse(&change.to)?,
|
||||
reason: if change.body.as_str().is_empty() {
|
||||
change.reason.clone()
|
||||
} else {
|
||||
format!("{}\n\n{}", change.reason, change.body.as_str())
|
||||
},
|
||||
references: change.references.clone(),
|
||||
author: change.author.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_workflow_state(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let change = self.decision_from_state_change(id, change)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let change = change.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_workflow_state_set(
|
||||
@@ -179,15 +205,23 @@ impl BackendWorkspaceProductClient {
|
||||
id: &TicketIdOrSlug,
|
||||
resolution: &MarkdownText,
|
||||
) -> Result<(), BackendWorkspaceClientError> {
|
||||
let current = self.show_ticket(id)?;
|
||||
let request = ticket::TicketCompletion {
|
||||
operation_key: uuid::Uuid::now_v7().to_string(),
|
||||
expected_item_revision: ticket::ticket_item_revision(¤t),
|
||||
expected_state: current.meta.workflow_state,
|
||||
reason: resolution.as_str().to_string(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
};
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
let resolution = resolution.clone();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_close_record(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketCloseRecordRequest(resolution),
|
||||
server_api::TicketCloseRecordRequest(request),
|
||||
)
|
||||
.await
|
||||
})
|
||||
@@ -633,6 +667,9 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
id: TicketIdOrSlug,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
self.generated(move |client| async move {
|
||||
@@ -672,6 +709,14 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
field: &str,
|
||||
change: TicketStateChange,
|
||||
) -> ticket::Result<()> {
|
||||
if field != "state" {
|
||||
return Err(TicketError::Conflict(
|
||||
"unsupported Ticket state field".into(),
|
||||
));
|
||||
}
|
||||
let change = self
|
||||
.decision_from_state_change(&id, &change)
|
||||
.map_err(ticket_client_error)?;
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
let field = field.to_string();
|
||||
@@ -697,6 +742,26 @@ impl TicketBackend for BackendWorkspaceProductClient {
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn update_state(
|
||||
&self,
|
||||
ticket: &str,
|
||||
request: ticket::TicketStateUpdate,
|
||||
) -> ticket::Result<Ticket> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket.to_owned();
|
||||
self.generated(move |client| async move {
|
||||
client
|
||||
.ticket_state_update(
|
||||
workspace_id,
|
||||
id,
|
||||
server_api::TicketStateUpdateRequest(request),
|
||||
)
|
||||
.await
|
||||
.map(|response| response.0)
|
||||
})
|
||||
.map_err(ticket_client_error)
|
||||
}
|
||||
|
||||
fn mark_ready(&self, id: TicketIdOrSlug, request: TicketMarkReady) -> ticket::Result<Ticket> {
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(&id);
|
||||
@@ -903,8 +968,12 @@ mod tests {
|
||||
}
|
||||
|
||||
fn response_sequence_server(
|
||||
responses: Vec<(&'static str, &'static str)>,
|
||||
responses: Vec<(&str, &str)>,
|
||||
) -> (String, mpsc::Receiver<String>, thread::JoinHandle<()>) {
|
||||
let responses = responses
|
||||
.into_iter()
|
||||
.map(|(status, body)| (status.to_owned(), body.to_owned()))
|
||||
.collect::<Vec<_>>();
|
||||
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let (sender, receiver) = mpsc::channel();
|
||||
@@ -927,6 +996,117 @@ mod tests {
|
||||
(format!("http://{address}"), receiver, handle)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_state_adapter_uses_snapshot_revision_without_overriding_stale_expected_state() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend.create(NewTicket::new("Adapter snapshot")).unwrap();
|
||||
let snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
let body = serde_json::to_string(&snapshot).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
|
||||
.unwrap();
|
||||
client
|
||||
.set_ticket_workflow_state(
|
||||
&reference.id.clone().into(),
|
||||
&TicketStateChange::new("ready", "done", "Judgment", "Results in the thread"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(requests.recv().unwrap().starts_with("GET "));
|
||||
let request = requests.recv().unwrap();
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body["expected_item_revision"],
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
);
|
||||
assert_eq!(body["expected_state"], "ready");
|
||||
assert_eq!(body["state"], "done");
|
||||
assert!(!body["operation_key"].as_str().unwrap().is_empty());
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn legacy_close_adapter_forwards_snapshot_cas_and_resolution_as_judgment() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let backend =
|
||||
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
|
||||
.unwrap();
|
||||
let reference = backend
|
||||
.create(NewTicket::new("Close adapter snapshot"))
|
||||
.unwrap();
|
||||
let snapshot = backend.show(reference.id.clone().into()).unwrap();
|
||||
let body = serde_json::to_string(&snapshot).unwrap();
|
||||
let (base_url, requests, handle) =
|
||||
response_sequence_server(vec![("200 OK", &body), ("204 No Content", "")]);
|
||||
let client =
|
||||
BackendWorkspaceProductClient::new_with_access_token(base_url, "workspace-a", "token")
|
||||
.unwrap();
|
||||
client
|
||||
.close_ticket(
|
||||
&reference.id.into(),
|
||||
&MarkdownText::new("The request was withdrawn"),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(requests.recv().unwrap().starts_with("GET "));
|
||||
let request = requests.recv().unwrap();
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(
|
||||
body["expected_item_revision"],
|
||||
ticket::ticket_item_revision(&snapshot)
|
||||
);
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "The request was withdrawn");
|
||||
assert!(!body["operation_key"].as_str().unwrap().is_empty());
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ticket_backend_completion_forwards_cas_judgment_and_backend_conflicts() {
|
||||
let (base_url, request, handle) = one_response_server(
|
||||
"409 Conflict",
|
||||
r#"{"error":"ticket_conflict","message":"stale revision","diagnostics":[]}"#,
|
||||
);
|
||||
let client = BackendWorkspaceProductClient::new_with_access_token(
|
||||
base_url,
|
||||
"workspace-a",
|
||||
"test-token",
|
||||
)
|
||||
.unwrap();
|
||||
let result = TicketBackend::complete(
|
||||
&client,
|
||||
"T-718",
|
||||
ticket::TicketCompletion {
|
||||
operation_key: "research-done".into(),
|
||||
expected_item_revision: "ticket:1".into(),
|
||||
expected_state: ticket::TicketWorkflowState::Planning,
|
||||
reason: "Research findings recorded".into(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
},
|
||||
);
|
||||
assert!(result.is_err());
|
||||
let request = request.recv().unwrap();
|
||||
assert!(
|
||||
request.starts_with("POST /api/w/workspace-a/tickets/T-718/state-update "),
|
||||
"{request}"
|
||||
);
|
||||
let body: serde_json::Value =
|
||||
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
|
||||
assert_eq!(body["state"], "done");
|
||||
assert_eq!(body["operation_key"], "research-done");
|
||||
assert_eq!(body["expected_item_revision"], "ticket:1");
|
||||
assert_eq!(body["expected_state"], "planning");
|
||||
assert_eq!(body["reason"], "Research findings recorded");
|
||||
assert!(body.get("merge_request_ids").is_none());
|
||||
handle.join().unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn memory_document_uses_shared_workspace_scoped_response() {
|
||||
let body = r##"{"body_md":"# Memory\\n","created_at":"2026-09-01T00:00:00Z","updated_at":"2026-09-02T00:00:00Z","bytes":10,"record_source":"workspace-sqlite"}"##;
|
||||
|
||||
Reference in New Issue
Block a user