feat: separate Ticket case state and immutable closure judgments

This commit is contained in:
2026-10-11 01:41:46 +09:00
parent 6a12b6cc34
commit ba3ebaa68c
65 changed files with 7625 additions and 6844 deletions
+276 -239
View File
@@ -20,6 +20,7 @@ use ticket::{
use crate::{BackendApiClient, BackendWorkspaceClientError};
const DEFAULT_PRODUCT_LIST_LIMIT: usize = 1_000;
const UNSUPPORTED_TICKET_STATE_CHANGE: &str = "unsupported legacy Ticket state/close operation; use TicketBackend::update_case with caller-supplied content digest, case state, closure operation key, and replay key (CAS); case updates do not start or stop work";
/// Workspace-scoped Backend client for Ticket and Objective product state.
///
@@ -155,76 +156,24 @@ 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_content_digest: ticket::ticket_content_digest(&current),
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,
_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);
self.generated(move |client| async move {
client
.ticket_workflow_state_set(
workspace_id,
id,
server_api::TicketStateChangeRequest(change),
)
.await
})
Err(BackendWorkspaceClientError::InvalidTarget(
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
))
}
pub fn close_ticket(
&self,
id: &TicketIdOrSlug,
resolution: &MarkdownText,
_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_content_digest: ticket::ticket_content_digest(&current),
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);
self.generated(move |client| async move {
client
.ticket_close_record(
workspace_id,
id,
server_api::TicketCloseRecordRequest(request),
)
.await
})
Err(BackendWorkspaceClientError::InvalidTarget(
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
))
}
pub fn add_ticket_relation(
@@ -664,24 +613,12 @@ impl TicketBackend for BackendWorkspaceProductClient {
fn add_state_changed(
&self,
id: TicketIdOrSlug,
change: TicketStateChange,
_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 {
client
.ticket_state_change_add(
workspace_id,
id,
server_api::TicketStateChangeRequest(change),
)
.await
})
.map_err(ticket_client_error)
Err(TicketError::Conflict(
UNSUPPORTED_TICKET_STATE_CHANGE.into(),
))
}
fn add_intake_summary(
@@ -703,59 +640,31 @@ impl TicketBackend for BackendWorkspaceProductClient {
.map_err(ticket_client_error)
}
fn set_state_field(
&self,
id: TicketIdOrSlug,
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();
self.generated(move |client| async move {
client
.ticket_state_field_set(
workspace_id,
id,
field,
server_api::TicketStateChangeRequest(change),
)
.await
})
.map_err(ticket_client_error)
}
fn set_workflow_state(
&self,
id: TicketIdOrSlug,
change: TicketStateChange,
) -> ticket::Result<()> {
self.set_ticket_workflow_state(&id, &change)
.map_err(ticket_client_error)
}
fn update_state(
fn update_case(
&self,
ticket: &str,
request: ticket::TicketStateUpdate,
request: ticket::TicketCaseUpdate,
) -> ticket::Result<Ticket> {
let workspace_id = self.workspace_id.clone();
let id = ticket.to_owned();
// Actor identity belongs to authenticated Backend authority, not the caller.
let request = server_api::TicketCaseUpdateRequest {
expected_content_digest: request.expected_content_digest,
expected_state: request.expected_state,
expected_closure_operation_key: request.expected_closure_operation_key,
operation_key: request.operation_key,
action: request.action,
explanation: request.explanation,
references: request.references,
};
self.generated(move |client| async move {
client
.ticket_state_update(
workspace_id,
id,
server_api::TicketStateUpdateRequest(request),
)
.ticket_case_update(workspace_id.clone(), id.clone(), request)
.await?;
// The case endpoint returns only a summary. Fetch the authoritative
// full record rather than fabricating body, events, or case history.
client
.ticket_record_get(workspace_id, id)
.await
.map(|response| response.0)
})
@@ -795,11 +704,6 @@ impl TicketBackend for BackendWorkspaceProductClient {
.map_err(ticket_client_error)
}
fn close(&self, id: TicketIdOrSlug, resolution: MarkdownText) -> ticket::Result<()> {
self.close_ticket(&id, &resolution)
.map_err(ticket_client_error)
}
fn add_ticket_relation(
&self,
id: TicketIdOrSlug,
@@ -941,6 +845,33 @@ mod tests {
use super::*;
fn read_http_request(stream: &mut std::net::TcpStream) -> String {
stream
.set_read_timeout(Some(std::time::Duration::from_secs(5)))
.unwrap();
let mut request = Vec::new();
let mut buffer = [0_u8; 4_096];
loop {
let bytes = stream.read(&mut buffer).unwrap();
assert_ne!(bytes, 0, "incomplete HTTP request");
request.extend_from_slice(&buffer[..bytes]);
if let Some(header_end) = request.windows(4).position(|bytes| bytes == b"\r\n\r\n") {
let headers = std::str::from_utf8(&request[..header_end]).unwrap();
let content_length = headers
.lines()
.find_map(|line| {
let (name, value) = line.split_once(':')?;
name.eq_ignore_ascii_case("content-length")
.then(|| value.trim().parse::<usize>().unwrap())
})
.unwrap_or(0);
if request.len() >= header_end + 4 + content_length {
return String::from_utf8(request).unwrap();
}
}
}
}
fn one_response_server(
status: &str,
body: &str,
@@ -952,11 +883,7 @@ mod tests {
let (sender, receiver) = mpsc::channel();
let handle = thread::spawn(move || {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0_u8; 8_192];
let bytes = stream.read(&mut request).unwrap();
sender
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
.unwrap();
sender.send(read_http_request(&mut stream)).unwrap();
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
@@ -980,11 +907,7 @@ mod tests {
let handle = thread::spawn(move || {
for (status, body) in responses {
let (mut stream, _) = listener.accept().unwrap();
let mut request = vec![0_u8; 16_384];
let bytes = stream.read(&mut request).unwrap();
sender
.send(String::from_utf8_lossy(&request[..bytes]).to_string())
.unwrap();
sender.send(read_http_request(&mut stream)).unwrap();
write!(
stream,
"HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
@@ -997,114 +920,228 @@ mod tests {
}
#[test]
fn legacy_state_adapter_uses_snapshot_content_without_overriding_stale_expected_state() {
fn legacy_state_and_close_calls_require_explicit_case_cas_without_http() {
let listener = TcpListener::bind("127.0.0.1:0").unwrap();
listener.set_nonblocking(true).unwrap();
let client = BackendWorkspaceProductClient::new_with_access_token(
format!("http://{}", listener.local_addr().unwrap()),
"workspace-a",
"token",
)
.unwrap();
let id = TicketIdOrSlug::Query("T-739".into());
let change = TicketStateChange::new("open", "closed", "Judgment", "Explanation");
let errors = [
client
.set_ticket_workflow_state(&id, &change)
.unwrap_err()
.to_string(),
client
.close_ticket(&id, &MarkdownText::new("Withdrawn"))
.unwrap_err()
.to_string(),
TicketBackend::add_state_changed(&client, id, change)
.unwrap_err()
.to_string(),
];
for error in errors {
assert!(
error.contains("unsupported legacy Ticket state/close operation"),
"{error}"
);
assert!(error.contains("TicketBackend::update_case"), "{error}");
assert!(error.contains("caller-supplied"), "{error}");
assert!(error.contains("CAS"), "{error}");
}
assert_eq!(
listener.accept().unwrap_err().kind(),
std::io::ErrorKind::WouldBlock
);
}
#[test]
fn serialized_update_case_forwards_tagged_actions_and_returns_full_backend_record() {
for action in [
serde_json::json!({"action": "close", "reason": "completed"}),
serde_json::json!({"action": "close", "reason": {"other": "Custom judgment"}}),
serde_json::json!({"action": "correct_closure", "reason": "duplicate"}),
serde_json::json!({"action": "reopen"}),
] {
// SQLite only supplies hermetic response fixtures, never a client fallback.
let temp = tempfile::tempdir().unwrap();
let backend =
ticket::SqliteTicketBackend::open(temp.path().join("fixture.db"), "workspace-a")
.unwrap();
let mut input = NewTicket::new("Full case record");
input.body = MarkdownText::new("Full body absent from the endpoint summary");
let reference = backend.create(input).unwrap();
let mut snapshot = backend.show(reference.id.clone().into()).unwrap();
if action["action"] != "close" {
snapshot = backend
.update_case(
&reference.id,
ticket::TicketCaseUpdate {
expected_content_digest: ticket::ticket_content_digest(&snapshot),
expected_state: ticket::TicketState::Open,
expected_closure_operation_key: None,
operation_key: "initial-close".into(),
action: ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Unknown,
},
explanation: "Initial judgment".into(),
references: Vec::new(),
author: Some("authenticated-actor".into()),
},
)
.unwrap();
}
let expected_request = serde_json::json!({
"expected_content_digest": ticket::ticket_content_digest(&snapshot),
"expected_state": snapshot.meta.workflow_state.as_str(),
"expected_closure_operation_key": snapshot.closure_operation_key,
"operation_key": "caller-case-key",
"action": action,
"explanation": "Caller judgment, not work progress",
"references": [{"kind": "ticket", "target": "T-738"}],
});
let mut domain_request = expected_request.clone();
domain_request["author"] = serde_json::json!("caller-cannot-impersonate");
let operation =
serde_json::from_value::<ticket::TicketBackendOperation>(serde_json::json!({
"operation": "update_case",
"ticket": reference.id,
"request": domain_request,
}))
.unwrap();
let mut fixture_request: ticket::TicketCaseUpdate =
serde_json::from_value(expected_request.clone()).unwrap();
fixture_request.author = Some("authenticated-actor".into());
let expected = backend.update_case(&reference.id, fixture_request).unwrap();
let summary =
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
let full_record = serde_json::to_string(&expected).unwrap();
let (base_url, requests, handle) =
response_sequence_server(vec![("200 OK", &summary), ("200 OK", &full_record)]);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-token",
)
.unwrap();
let result = ticket::execute_ticket_backend_operation(&client, operation).unwrap();
let ticket::TicketBackendOperationResult::Ticket(actual) = result else {
panic!("case operation must return a full Ticket");
};
assert_eq!(actual, expected);
let post = requests.recv().unwrap();
assert!(
post.starts_with(&format!(
"POST /workspaces/workspace-a/tickets/{}/case ",
reference.id,
)),
"{post}"
);
assert!(
post.contains("authorization: Bearer test-token\r\n"),
"{post}"
);
let body: serde_json::Value =
serde_json::from_str(post.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body, expected_request);
assert!(body.get("author").is_none());
assert!(requests.recv().unwrap().starts_with(&format!(
"GET /api/w/workspace-a/tickets/{}/record ",
reference.id,
)));
handle.join().unwrap();
}
}
fn case_request() -> ticket::TicketCaseUpdate {
ticket::TicketCaseUpdate {
operation_key: "caller-case-key".into(),
expected_content_digest: "caller-content-digest".into(),
expected_state: ticket::TicketState::Closed,
expected_closure_operation_key: Some("last-close-key".into()),
action: ticket::TicketCaseAction::Reopen,
explanation: "Caller reopening judgment".into(),
references: Vec::new(),
author: None,
}
}
#[test]
fn case_update_rejection_preserves_caller_cas_without_local_fallback() {
for (status, body, expected_error) in [
(
"409 Conflict",
r#"{"error":"ticket_conflict","message":"stale content","diagnostics":[]}"#,
"409",
),
(
"403 Forbidden",
r#"{"error":"forbidden","message":"not authorized","diagnostics":[]}"#,
"403",
),
] {
let (base_url, requests, handle) = one_response_server(status, body);
let client = BackendWorkspaceProductClient::new_with_access_token(
base_url,
"workspace-a",
"test-token",
)
.unwrap();
let error = TicketBackend::update_case(&client, "T-739", case_request()).unwrap_err();
assert!(
error.to_string().contains(expected_error),
"{error}; response {status}: {body}"
);
let request = requests.recv().unwrap();
assert!(
request.starts_with("POST /workspaces/workspace-a/tickets/T-739/case "),
"{request}"
);
let body: serde_json::Value =
serde_json::from_str(request.split("\r\n\r\n").nth(1).unwrap()).unwrap();
assert_eq!(body["expected_content_digest"], "caller-content-digest");
assert_eq!(body["expected_state"], "closed");
assert_eq!(body["expected_closure_operation_key"], "last-close-key");
assert_eq!(body["operation_key"], "caller-case-key");
assert_eq!(body["action"], serde_json::json!({"action": "reopen"}));
assert!(!error.to_string().contains("test-token"));
handle.join().unwrap();
assert!(requests.try_recv().is_err());
}
}
#[test]
fn successful_case_update_with_failed_show_does_not_fabricate_full_ticket() {
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_content_digest"],
ticket::ticket_content_digest(&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_content_digest"],
ticket::ticket_content_digest(&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 content","diagnostics":[]}"#,
);
let reference = backend.create(NewTicket::new("Summary only")).unwrap();
let summary =
serde_json::to_string(&backend.list(TicketListQuery::all()).unwrap()[0]).unwrap();
let (base_url, requests, handle) = response_sequence_server(vec![
("200 OK", &summary),
(
"404 Not Found",
r#"{"error":"ticket_not_found","message":"full record unavailable","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_content_digest: "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_content_digest"], "ticket:1");
assert_eq!(body["expected_state"], "planning");
assert_eq!(body["reason"], "Research findings recorded");
assert!(body.get("merge_request_ids").is_none());
let error = TicketBackend::update_case(&client, &reference.id, case_request()).unwrap_err();
assert!(error.to_string().contains("404"), "{error}");
assert!(requests.recv().unwrap().starts_with("POST /workspaces/"));
assert!(requests.recv().unwrap().starts_with("GET /api/w/"));
handle.join().unwrap();
assert!(requests.try_recv().is_err());
}
#[test]
@@ -1414,12 +1451,12 @@ mod tests {
assert_eq!(
ticket_list_state_query(&TicketListQuery {
state: TicketStateSelector::States(
[TicketListState::Ready, TicketListState::InProgress]
[TicketListState::Open, TicketListState::Closed]
.into_iter()
.collect(),
),
}),
"ready,inprogress"
"open,closed"
);
}
+1 -1
View File
@@ -1329,7 +1329,7 @@ mod tests {
assert_eq!(manifest.engine.instruction, "role.ticket_worker");
// enabled supplies ShowTicket; thread supplies TicketComment,
// TicketWorkflowState and CompleteTicket without broad workflow access.
// TicketCaseUpdate without broad workflow access.
let feature = &manifest.feature;
assert!(feature.ticket.enabled);
assert!(feature.ticket.thread);
+6 -24
View File
@@ -1159,18 +1159,9 @@ impl MergeRequestStore {
"approval predates the current Ticket digest; fresh review is required".into(),
));
}
let state: Option<String> = connection
.query_row(
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
params![mr.workspace_id, i.ticket_id],
|row| row.get(0),
)
.optional()?;
if state.as_deref() != Some("inprogress") {
return Err(MergeRequestError::Conflict(
"Ticket must be inprogress".into(),
));
}
// Case openness is not an integration phase. Exact source approval,
// current Ticket content and active assignment fencing above remain
// the independent MR integration guards.
Ok(())
}
@@ -1239,18 +1230,9 @@ impl MergeRequestStore {
"approval predates the current Ticket digest; fresh review is required".into(),
));
}
let state: Option<String> = transaction
.query_row(
"SELECT workflow_state FROM typed_tickets WHERE workspace_id=?1 AND ticket_id=?2",
params![mr.workspace_id, i.ticket_id],
|row| row.get(0),
)
.optional()?;
if state.as_deref() != Some("inprogress") {
return Err(MergeRequestError::Conflict(
"Ticket must be inprogress".into(),
));
}
// Case openness is not an integration phase. Exact source approval,
// current Ticket content and active assignment fencing above remain
// the independent MR integration guards.
let issued_grants = {
let mut statement = transaction.prepare(
"SELECT request_event_id,subject_ref,capability_token
+236 -133
View File
@@ -306,14 +306,11 @@ impl_openapi_schema!(
SkillDetailResponse,
SkillActivationResponse,
BrowserAppendTicketEventRequest,
BrowserCloseTicketRequest,
BrowserEditTicketRequest,
BrowserQueueTicketRequest,
BrowserTransitionTicketStateRequest,
CancelTicketImplementationRequest,
ClearTicketRoleAssignmentQuery,
CompleteMergeRequestRequest,
CompleteTicketRequest,
CreateTicketOrchestrationPlanRequest,
CreateTicketRecordRequest,
CreateTicketRelationRequest,
@@ -347,9 +344,10 @@ impl_openapi_schema!(
SetTicketRoleAssignmentRequest,
SubmitMergeRequestReviewRequest,
TextResponse,
TicketCloseRecordRequest,
TicketDependencyCheckResponse,
TicketDetail,
TicketCaseUpdateRequest,
TicketSummary,
TicketDoctorResponse,
TicketIntakeSummaryRequest,
TicketListHttpQuery,
@@ -372,8 +370,6 @@ impl_openapi_schema!(
TicketRoleAssignmentMutationResponse,
TicketRoleAssignmentsResponse,
TicketShowRequest,
TicketStateChangeRequest,
TicketStateUpdateRequest,
TicketSummarySearchQuery,
TicketThreadEventRequest,
RuntimeManagementApiError,
@@ -3051,14 +3047,6 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketThreadEventRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-changes", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_change_add(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/intake-summaries", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_intake_summary_add(
&self,
@@ -3067,31 +3055,6 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: TicketIntakeSummaryRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-fields/{field}", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_field_set(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[path] field: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow-state", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_workflow_state_set(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketStateChangeRequest,
) -> Result<(), RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state-update", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_update(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketStateUpdateRequest,
) -> Result<TicketRecord, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/mark-ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_mark_ready_record(
&self,
@@ -3189,22 +3152,14 @@ pub trait ServerApi {
#[path] id: String,
#[body] request: CompleteMergeRequestRequest,
) -> Result<MergeEvent, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/complete", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_complete(
#[post("/workspaces/{workspace_id}/tickets/{ticket_id}/case", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_case_update(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: CompleteTicketRequest,
) -> Result<TicketRecord, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/workflow/close", status = 204, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_close_record(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: TicketCloseRecordRequest,
) -> Result<(), RepositoryApiError>;
#[path] ticket_id: String,
#[body] request: TicketCaseUpdateRequest,
) -> Result<TicketSummary, RepositoryApiError>;
#[get("/api/w/{workspace_id}/tickets/{id}/relation-view", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_relation_view(
&self,
@@ -3292,15 +3247,6 @@ pub trait ServerApi {
#[body] request: CancelTicketImplementationRequest,
) -> Result<TicketDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/state", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_state_transition(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserTransitionTicketStateRequest,
) -> Result<TicketDetail, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/ready", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_ready(
&self,
@@ -3327,14 +3273,6 @@ pub trait ServerApi {
#[body] request: BrowserQueueTicketRequest,
) -> Result<TicketQueueResponse, RepositoryApiError>;
#[post("/api/w/{workspace_id}/tickets/{id}/close", status = 200, error_status = 400, additional_error_statuses = [401, 403, 404, 409, 422, 500], bearer_auth = true, browser_auth = true)]
async fn ticket_close(
&self,
#[extension] context: ServerRequestContext,
#[path] workspace_id: String,
#[path] id: String,
#[body] request: BrowserCloseTicketRequest,
) -> Result<TicketDetail, RepositoryApiError>;
#[get("/api/objectives", status = 200, error_status = 400, additional_error_statuses = [500], bearer_auth = true, browser_auth = true)]
async fn objective_list_alias(
&self,
@@ -7263,13 +7201,23 @@ pub struct TicketListItemSummary {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub state: BrowserTicketState,
pub priority: String,
pub updated_at: Option<String>,
pub queued_by: Option<String>,
pub queued_at: Option<String>,
pub workspace_action_priority: String,
pub record_source: String,
/// Last closure/correction key, retained across reopening for ABA-safe CAS.
pub closure_operation_key: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))]
pub current_closure: Option<ticket::TicketClosureJudgment>,
/// Original migration handoff; not another current state authority.
#[cfg_attr(
feature = "typescript",
ts(type = "TicketLegacyWorkflowSnapshot | null")
)]
pub legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -7553,20 +7501,49 @@ pub struct TicketEvidenceSummary {
pub missing: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum TicketAttention {
UnresolvedReview,
MissingCommit,
Blocked,
Unblocked,
AwaitingReview,
UnresolvedChanges,
StaleAfterRescope,
MissingEvidence,
}
impl TicketAttention {
pub const fn as_str(&self) -> &'static str {
match self {
Self::UnresolvedReview => "unresolved_review",
Self::MissingCommit => "missing_commit",
Self::Blocked => "blocked",
Self::Unblocked => "unblocked",
Self::AwaitingReview => "awaiting_review",
Self::UnresolvedChanges => "unresolved_changes",
Self::StaleAfterRescope => "stale_after_rescope",
Self::MissingEvidence => "missing_evidence",
}
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct TicketQueryRequest {
pub query: Option<String>,
#[serde(default)]
pub states: Vec<String>,
pub states: Vec<BrowserTicketState>,
#[serde(default)]
pub event_kinds: Vec<String>,
#[serde(default)]
pub evidence: Vec<String>,
pub review_status: Option<String>,
#[serde(default)]
pub attention: Vec<String>,
pub attention: Vec<TicketAttention>,
pub related_ticket_id: Option<String>,
pub relation_kind: Option<String>,
pub linked_objective_id: Option<String>,
@@ -7585,7 +7562,7 @@ pub struct TicketQueryItem {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub state: BrowserTicketState,
pub readiness: Option<String>,
pub priority: String,
pub created_at: Option<String>,
@@ -7613,6 +7590,16 @@ pub struct TicketQueryItem {
pub merge_requests: Vec<TicketMergeRequestSummary>,
pub merge_request: Option<TicketMergeRequestSummary>,
pub current_worker: Option<TicketAssignmentSummary>,
/// Last closure/correction key, retained across reopening for ABA-safe CAS.
pub closure_operation_key: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))]
pub current_closure: Option<ticket::TicketClosureJudgment>,
/// Original migration handoff; not another current state authority.
#[cfg_attr(
feature = "typescript",
ts(type = "TicketLegacyWorkflowSnapshot | null")
)]
pub legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -7640,7 +7627,7 @@ pub struct TicketDetail {
pub id: String,
pub resource_key: String,
pub title: String,
pub state: String,
pub state: BrowserTicketState,
pub readiness: Option<String>,
pub priority: String,
pub created_at: Option<String>,
@@ -7674,6 +7661,18 @@ pub struct TicketDetail {
pub evidence: TicketEvidenceSummary,
pub resolution: Option<String>,
pub record_source: String,
/// Last closure/correction key, retained across reopening for ABA-safe CAS.
pub closure_operation_key: Option<String>,
#[cfg_attr(feature = "typescript", ts(type = "TicketClosureJudgment | null"))]
pub current_closure: Option<ticket::TicketClosureJudgment>,
/// Original migration handoff; not another current state authority.
#[cfg_attr(
feature = "typescript",
ts(type = "TicketLegacyWorkflowSnapshot | null")
)]
pub legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
#[cfg_attr(feature = "typescript", ts(type = "Array<TicketCaseHistoryEntry>"))]
pub closure_history: Vec<ticket::TicketCaseHistoryEntry>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
@@ -7703,6 +7702,34 @@ macro_rules! transparent_ticket_dto {
}
transparent_ticket_dto!(TicketRecord, ticket::Ticket);
transparent_ticket_dto!(TicketSummary, ticket::TicketSummary);
/// Authorized case judgment. Author comes from authenticated Workspace authority,
/// never from the request. All three actions share content/state/closure-key CAS.
fn deserialize_case_closure_observation<'de, D: serde::Deserializer<'de>>(
deserializer: D,
) -> Result<Option<String>, D::Error> {
Option::<String>::deserialize(deserializer)
}
fn case_closure_observation_schema(generator: &mut schemars::SchemaGenerator) -> schemars::Schema {
generator.subschema_for::<Option<String>>()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct TicketCaseUpdateRequest {
pub expected_content_digest: String,
pub expected_state: ticket::TicketState,
#[serde(deserialize_with = "deserialize_case_closure_observation")]
#[schemars(required, schema_with = "case_closure_observation_schema")]
pub expected_closure_operation_key: Option<String>,
pub operation_key: String,
pub action: ticket::TicketCaseAction,
pub explanation: String,
#[serde(default)]
pub references: Vec<ticket::TicketReference>,
}
transparent_ticket_dto!(TicketRecordRef, ticket::TicketRef);
transparent_ticket_dto!(TicketRecordSummaryList, Vec<ticket::TicketSummary>);
transparent_ticket_dto!(TicketDependencyCheckResponse, ticket::TicketDependencyCheck);
@@ -7722,10 +7749,7 @@ transparent_ticket_dto!(
transparent_ticket_dto!(CreateTicketRecordRequest, ticket::NewTicket);
transparent_ticket_dto!(EditTicketRecordItemRequest, ticket::TicketItemEdit);
transparent_ticket_dto!(TicketThreadEventRequest, ticket::NewTicketEvent);
transparent_ticket_dto!(TicketStateChangeRequest, ticket::TicketStateUpdate);
transparent_ticket_dto!(TicketStateUpdateRequest, ticket::TicketStateUpdate);
transparent_ticket_dto!(TicketIntakeSummaryRequest, ticket::TicketIntakeSummary);
transparent_ticket_dto!(TicketCloseRecordRequest, ticket::TicketCompletion);
transparent_ticket_dto!(CreateTicketRelationRequest, ticket::NewTicketRelation);
transparent_ticket_dto!(
CreateTicketOrchestrationPlanRequest,
@@ -7837,15 +7861,29 @@ pub enum BrowserTicketTargetEdit {
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
pub enum BrowserTicketWorkflowState {
Planning,
Ready,
Queued,
Inprogress,
Done,
pub enum BrowserTicketState {
Open,
Closed,
}
impl BrowserTicketState {
pub const fn as_str(&self) -> &'static str {
match self {
Self::Open => "open",
Self::Closed => "closed",
}
}
}
impl From<ticket::TicketState> for BrowserTicketState {
fn from(state: ticket::TicketState) -> Self {
match state {
ticket::TicketState::Open => Self::Open,
ticket::TicketState::Closed => Self::Closed,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
@@ -7860,18 +7898,6 @@ pub struct BrowserEditTicketRequest {
pub author: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct BrowserTransitionTicketStateRequest {
pub state: BrowserTicketWorkflowState,
pub operation_key: String,
pub expected_content_digest: String,
pub expected_state: BrowserTicketWorkflowState,
pub reason: String,
pub body: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(rename_all = "snake_case")]
@@ -7896,16 +7922,6 @@ pub struct BrowserAppendTicketEventRequest {
#[serde(deny_unknown_fields)]
pub struct BrowserQueueTicketRequest {}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
#[serde(deny_unknown_fields)]
pub struct BrowserCloseTicketRequest {
pub operation_key: String,
pub expected_content_digest: String,
pub expected_state: BrowserTicketWorkflowState,
pub resolution: String,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct ObjectiveListQuery {
@@ -8278,17 +8294,6 @@ pub struct CompleteMergeRequestRequest {
pub resolution: ConflictResolution,
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct CompleteTicketRequest {
pub operation_key: String,
pub expected_content_digest: String,
pub expected_state: ticket::TicketWorkflowState,
pub reason: String,
#[serde(default)]
pub references: Vec<ticket::TicketReference>,
}
#[derive(Debug, Clone, Serialize, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct RuntimeManagementApiError {
@@ -11550,14 +11555,9 @@ mod tests {
);
for (operation_id, method, path) in [
(
"ticket_state_update",
"ticket_case_update",
HttpMethod::Post,
"/api/w/{workspace_id}/tickets/{id}/state-update",
),
(
"ticket_complete",
HttpMethod::Post,
"/api/w/{workspace_id}/tickets/{id}/complete",
"/workspaces/{workspace_id}/tickets/{ticket_id}/case",
),
(
"ticket_relation_query",
@@ -12747,31 +12747,134 @@ mod tests {
}
#[test]
fn ticket_completion_wire_requires_cas_and_judgment_not_merge_evidence() {
fn ticket_case_wire_requires_cas_and_judgment_without_client_author() {
let request = serde_json::json!({
"operation_key": "investigation-done",
"operation_key": "question-answered",
"expected_content_digest": "ticket:1",
"expected_state": "planning",
"reason": "Question answered in the thread"
"expected_state": "open",
"expected_closure_operation_key": null,
"action": {"action": "close", "reason": "completed"},
"explanation": "Question answered in the thread"
});
let parsed: CompleteTicketRequest = serde_json::from_value(request.clone()).unwrap();
let parsed: TicketCaseUpdateRequest = serde_json::from_value(request.clone()).unwrap();
assert!(parsed.references.is_empty());
assert_eq!(parsed.expected_state, ticket::TicketState::Open);
for field in [
"operation_key",
"expected_content_digest",
"expected_state",
"reason",
"expected_closure_operation_key",
"action",
"explanation",
] {
let mut missing = request.clone();
missing.as_object_mut().unwrap().remove(field);
assert!(
serde_json::from_value::<CompleteTicketRequest>(missing).is_err(),
serde_json::from_value::<TicketCaseUpdateRequest>(missing).is_err(),
"{field}"
);
}
let mut old = request;
old["requirement_approval_event_id"] = serde_json::json!("not-a-judgment");
assert!(serde_json::from_value::<CompleteTicketRequest>(old).is_err());
for (field, value) in [
("author", serde_json::json!("untrusted-author")),
(
"requirement_approval_event_id",
serde_json::json!("not-a-judgment"),
),
("expected_state", serde_json::json!("done")),
] {
let mut rejected = request.clone();
rejected[field] = value;
assert!(
serde_json::from_value::<TicketCaseUpdateRequest>(rejected).is_err(),
"{field}"
);
}
}
#[test]
fn ticket_case_wire_preserves_actions_and_externally_tagged_other_reason() {
for action in [
serde_json::json!({"action": "close", "reason": "not_planned"}),
serde_json::json!({"action": "reopen"}),
serde_json::json!({"action": "correct_closure", "reason": {"other": "Deferred by user"}}),
] {
let request = serde_json::json!({
"operation_key": "case-decision",
"expected_content_digest": "ticket:2",
"expected_state": "closed",
"expected_closure_operation_key": "previous-close",
"action": action,
"explanation": "Explicit user decision",
"references": [{"kind": "ticket", "target": "T-2"}]
});
let parsed: TicketCaseUpdateRequest = serde_json::from_value(request.clone()).unwrap();
assert_eq!(serde_json::to_value(parsed).unwrap(), request);
}
}
#[test]
fn ticket_query_accepts_only_case_states_and_current_attention_filters() {
let parsed: TicketQueryRequest = serde_json::from_value(serde_json::json!({
"states": ["open", "closed"], "attention": ["blocked", "missing_evidence"]
}))
.unwrap();
assert_eq!(parsed.states[0], BrowserTicketState::Open);
for state in [
"planning",
"ready",
"queued",
"inprogress",
"in_progress",
"done",
] {
assert!(
serde_json::from_value::<TicketQueryRequest>(
serde_json::json!({"states": [state]})
)
.is_err(),
"{state}"
);
}
for attention in ["done_not_closed", "ready"] {
assert!(
serde_json::from_value::<TicketQueryRequest>(
serde_json::json!({"attention": [attention]})
)
.is_err(),
"{attention}"
);
}
}
#[test]
fn ticket_case_route_replaces_all_legacy_case_judgment_bypasses() {
let operations = ServerApiMetadata::OPERATIONS;
for removed in [
"ticket_complete",
"ticket_close",
"ticket_close_record",
"ticket_state_update",
"ticket_state_transition",
"ticket_state_change_add",
"ticket_state_field_set",
"ticket_workflow_state_set",
] {
assert!(
!operations
.iter()
.any(|operation| operation.operation_id == removed),
"{removed}"
);
}
let operation = operations
.iter()
.find(|operation| operation.operation_id == "ticket_case_update")
.unwrap();
assert_eq!(operation.method, HttpMethod::Post);
assert_eq!(
operation.path,
"/workspaces/{workspace_id}/tickets/{ticket_id}/case"
);
}
#[cfg(feature = "typescript")]
@@ -466,11 +466,11 @@ fn validate_ticket_browser_operations(
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/state",
path: "/workspaces/{workspace_id}/tickets/{ticket_id}/case",
method: "post",
operation_id: "ticket_state_transition",
request: Some("BrowserTransitionTicketStateRequest"),
successes: &[("200", "TicketDetail")],
operation_id: "ticket_case_update",
request: Some("TicketCaseUpdateRequest"),
successes: &[("200", "TicketSummary")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/ready",
@@ -493,13 +493,6 @@ fn validate_ticket_browser_operations(
request: Some("BrowserQueueTicketRequest"),
successes: &[("200", "TicketQueueOutcome")],
},
OperationContract {
path: "/api/w/{workspace_id}/tickets/{id}/close",
method: "post",
operation_id: "ticket_close",
request: Some("BrowserCloseTicketRequest"),
successes: &[("200", "TicketDetail")],
},
OperationContract {
path: "/api/w/{workspace_id}/objectives",
method: "get",
@@ -1891,6 +1884,54 @@ mod tests {
assert!(!detail.contains(" target:"));
}
#[test]
fn ticket_browser_generator_exposes_case_judgment_and_closure_projections() {
let openapi = crate::canonical_openapi_document()
.unwrap()
.to_json()
.unwrap();
let output = generate_ticket_browser_typescript(&openapi).unwrap();
assert!(output.contains("export type BrowserTicketState = \"open\" | \"closed\";"));
let request = output
.lines()
.find(|line| line.starts_with("export type TicketCaseUpdateRequest ="))
.unwrap();
for field in [
"expected_content_digest: string",
"expected_state: TicketState",
"expected_closure_operation_key: string | null",
"operation_key: string",
"action: TicketCaseAction",
"explanation: string",
] {
assert!(request.contains(field), "missing {field}: {request}");
}
assert!(!request.contains("author"));
assert!(output.contains("export type TicketClosureReason = \"completed\" | \"not_planned\" | \"duplicate\" | \"unknown\" | { other: string; };"));
let detail = output
.lines()
.find(|line| line.starts_with("export type TicketDetail ="))
.unwrap();
for field in [
"state: BrowserTicketState",
"closure_operation_key?: string | null",
"current_closure?: TicketClosureJudgment | null",
"closure_history: Array<TicketCaseHistoryEntry>",
"legacy_handoff?: TicketLegacyWorkflowSnapshot | null",
] {
assert!(detail.contains(field), "missing {field}: {detail}");
}
for removed in [
"BrowserTransitionTicketStateRequest",
"BrowserCloseTicketRequest",
"CompleteTicketRequest",
"BrowserTicketWorkflowState",
"done_not_closed",
] {
assert!(!output.contains(removed), "{removed}");
}
}
#[test]
fn checked_in_ticket_browser_typescript_is_current() {
assert_eq!(
+32
View File
@@ -0,0 +1,32 @@
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
CREATE TABLE typed_ticket_case_metadata (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
PRIMARY KEY (workspace_id, ticket_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_case_history (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, operation_key),
UNIQUE (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
WHEN NEW.workflow_state NOT IN ('open', 'closed')
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
WHEN NEW.workflow_state NOT IN ('open', 'closed')
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
+33
View File
@@ -131,3 +131,36 @@ CREATE INDEX typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
-- Ticket case authority. Legacy workflow data is a handoff snapshot, never current authority.
CREATE TABLE typed_ticket_case_metadata (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, closure_operation_key TEXT,
PRIMARY KEY (workspace_id, ticket_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_case_history (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL,
operation_key TEXT NOT NULL, request_fingerprint TEXT NOT NULL, judgment_json TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, operation_key),
UNIQUE (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_legacy_workflow_snapshot (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, snapshot_json TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TRIGGER typed_ticket_case_history_no_update BEFORE UPDATE ON typed_ticket_case_history
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
CREATE TRIGGER typed_ticket_case_history_no_delete BEFORE DELETE ON typed_ticket_case_history
BEGIN SELECT RAISE(ABORT, 'Ticket case history is immutable'); END;
CREATE TRIGGER typed_ticket_legacy_workflow_no_update BEFORE UPDATE ON typed_ticket_legacy_workflow_snapshot
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
CREATE TRIGGER typed_ticket_legacy_workflow_no_delete BEFORE DELETE ON typed_ticket_legacy_workflow_snapshot
BEGIN SELECT RAISE(ABORT, 'Ticket legacy workflow snapshot is immutable'); END;
CREATE TRIGGER typed_ticket_case_state_insert BEFORE INSERT ON typed_tickets
WHEN NEW.workflow_state NOT IN ('open', 'closed')
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
CREATE TRIGGER typed_ticket_case_state_update BEFORE UPDATE OF workflow_state ON typed_tickets
WHEN NEW.workflow_state NOT IN ('open', 'closed')
BEGIN SELECT RAISE(ABORT, 'Ticket case state must be open or closed'); END;
+488 -2018
View File
File diff suppressed because it is too large Load Diff
+151 -5
View File
@@ -7,7 +7,7 @@ use crate::{Result, TicketError, sqlite_err};
const MIGRATION_TABLE: &str = "ticket_schema_migrations";
const MAX_SCHEMA_DIAGNOSTICS: usize = 32;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 7;
const LATEST_SQLITE_TICKET_SCHEMA_VERSION: i64 = 9;
const PREVIOUS_SQLITE_TICKET_SCHEMA_VERSION: i64 = 6;
const BASELINE_MIGRATION_NAME: &str = "ticket schema baseline";
@@ -52,6 +52,9 @@ const OWNED_TABLES: &[&str] = &[
"typed_ticket_relations",
"typed_ticket_orchestration_plans",
"typed_ticket_artifacts",
"typed_ticket_case_metadata",
"typed_ticket_case_history",
"typed_ticket_legacy_workflow_snapshot",
"workspace_resource_keys",
];
@@ -290,6 +293,7 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
)])
{
migrate_v6_to_v7(connection)?;
migrate_v7_to_v9(connection)?;
connection
.execute(
"DELETE FROM ticket_schema_migrations WHERE version = ?1",
@@ -307,7 +311,11 @@ pub fn migrate_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
],
)
.map_err(sqlite_err)?;
} else if applied == BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_string())]) {
migrate_v7_to_v9(connection)?;
connection.execute("UPDATE ticket_schema_migrations SET version = 9, applied_at = ?1 WHERE version = 7", params![chrono::Utc::now().to_rfc3339()]).map_err(sqlite_err)?;
} else {
// Version 8 belongs to T-740; compose its tag migration explicitly at integration.
validate_applied_migrations(&applied)?;
}
@@ -448,6 +456,63 @@ pub fn verify_sqlite_ticket_schema(connection: &Connection) -> Result<()> {
collect_absent_table_diagnostic(connection, legacy_table, &mut diagnostics);
}
for (table, columns) in [
(
"typed_ticket_case_metadata",
vec![
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("closure_operation_key", "TEXT", false, 0),
],
),
(
"typed_ticket_case_history",
vec![
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("ordinal", "INTEGER", true, 0),
column("operation_key", "TEXT", true, 3),
column("request_fingerprint", "TEXT", true, 0),
column("judgment_json", "TEXT", true, 0),
],
),
(
"typed_ticket_legacy_workflow_snapshot",
vec![
column("workspace_id", "TEXT", true, 1),
column("ticket_id", "TEXT", true, 2),
column("snapshot_json", "TEXT", true, 0),
],
),
] {
if let Err(error) = verify_table(connection, table, &columns, CHILD_FOREIGN_KEYS, false) {
push_diagnostic(&mut diagnostics, error.to_string());
}
}
for name in [
"typed_ticket_case_history_no_update",
"typed_ticket_case_history_no_delete",
"typed_ticket_legacy_workflow_no_update",
"typed_ticket_legacy_workflow_no_delete",
"typed_ticket_case_state_insert",
"typed_ticket_case_state_update",
] {
let exists = connection
.query_row(
"SELECT 1 FROM sqlite_master WHERE type = 'trigger' AND name = ?1",
params![name],
|_| Ok(()),
)
.optional()
.map_err(sqlite_err)?
.is_some();
if !exists {
push_diagnostic(
&mut diagnostics,
format!("missing Ticket case trigger {name}"),
);
}
}
for table in OWNED_TABLES {
collect_foreign_key_check_diagnostics(connection, table, &mut diagnostics);
}
@@ -474,6 +539,85 @@ fn create_latest_ticket_schema(connection: &Connection) -> Result<()> {
.map_err(sqlite_err)
}
/// Independent 7→9 case migration. Does not rewrite historical events or
/// create/resume worker identities. Version 8 tag composition is deliberately separate.
fn migrate_v7_to_v9(connection: &Connection) -> Result<()> {
let snapshots = {
let mut stmt = connection.prepare("SELECT workspace_id, ticket_id, workflow_state, workflow_state_explicit, status, queued_by, queued_at, resolution FROM typed_tickets").map_err(sqlite_err)?;
stmt.query_map([], |row| {
Ok((
row.get::<_, String>(0)?,
row.get::<_, String>(1)?,
crate::TicketLegacyWorkflowSnapshot {
workflow_state: row.get(2)?,
workflow_state_explicit: row.get::<_, i64>(3)? != 0,
status: row.get(4)?,
queued_by: row.get(5)?,
queued_at: row.get(6)?,
resolution: row.get(7)?,
},
))
})
.map_err(sqlite_err)?
.collect::<std::result::Result<Vec<_>, _>>()
.map_err(sqlite_err)?
};
// Validate old authority before any writes; unknown states are not silently repaired.
for (_, _, snapshot) in &snapshots {
if !matches!(
snapshot.workflow_state.as_str(),
"planning" | "ready" | "queued" | "inprogress" | "done" | "closed"
) {
return Err(TicketError::Sqlite(format!(
"unsupported legacy Ticket workflow state {:?}",
snapshot.workflow_state
)));
}
}
// State guards are installed only after all six-state rows have been converted.
for (workspace_id, ticket_id, snapshot) in &snapshots {
let state = if matches!(snapshot.workflow_state.as_str(), "done" | "closed") {
"closed"
} else {
"open"
};
connection.execute("UPDATE typed_tickets SET workflow_state = ?3, status = ?3 WHERE workspace_id = ?1 AND ticket_id = ?2", params![workspace_id, ticket_id, state]).map_err(sqlite_err)?;
}
connection
.execute_batch(include_str!("case_schema.sql"))
.map_err(sqlite_err)?;
for (workspace_id, ticket_id, snapshot) in snapshots {
connection.execute("INSERT INTO typed_ticket_legacy_workflow_snapshot (workspace_id, ticket_id, snapshot_json) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, serde_json::to_string(&snapshot).map_err(sqlite_err)?]).map_err(sqlite_err)?;
if matches!(snapshot.workflow_state.as_str(), "done" | "closed") {
let key = format!("migration-v9:{ticket_id}");
let judgment = crate::TicketCaseHistoryEntry {
operation_key: key.clone(),
previous_closure_operation_key: None,
// No typed evidence in schema7 establishes Completed. `done` is not success.
action: crate::TicketCaseAction::Close {
reason: crate::TicketClosureReason::Unknown,
},
explanation: snapshot
.resolution
.clone()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| {
format!(
"Migrated legacy {} state; closure reason is unknown.",
snapshot.workflow_state
)
}),
references: Vec::new(),
author: "ticket-schema-migration-v9".into(),
at: chrono::Utc::now().to_rfc3339(),
};
connection.execute("INSERT INTO typed_ticket_case_history (workspace_id, ticket_id, ordinal, operation_key, request_fingerprint, judgment_json) VALUES (?1, ?2, 0, ?3, 'migration-v9', ?4)", params![workspace_id, ticket_id, key, serde_json::to_string(&judgment).map_err(sqlite_err)?]).map_err(sqlite_err)?;
connection.execute("INSERT INTO typed_ticket_case_metadata (workspace_id, ticket_id, closure_operation_key) VALUES (?1, ?2, ?3)", params![workspace_id, ticket_id, key]).map_err(sqlite_err)?;
}
}
Ok(())
}
fn migrate_v6_to_v7(connection: &Connection) -> Result<()> {
connection
.execute_batch(
@@ -1026,7 +1170,9 @@ mod tests {
#[test]
fn migrates_single_target_rows_to_ordered_read_write_targets() {
let connection = Connection::open_in_memory().unwrap();
create_latest_ticket_schema(&connection).unwrap();
connection
.execute_batch(include_str!("../tests/fixtures/schema7.sql"))
.unwrap();
connection
.execute_batch(
"DROP TABLE typed_ticket_targets;
@@ -1137,7 +1283,7 @@ mod tests {
assert!(!migrated_attributes.contains_key("ref_selector"));
assert_eq!(
load_applied_migrations(&connection).unwrap(),
BTreeMap::from([(7, BASELINE_MIGRATION_NAME.to_owned())])
BTreeMap::from([(9, BASELINE_MIGRATION_NAME.to_owned())])
);
}
@@ -1155,7 +1301,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 7 baseline marker"
"migration history must contain only the canonical version 9 baseline marker"
));
assert_eq!(load_applied_migrations(&connection).unwrap().len(), 2);
}
@@ -1177,7 +1323,7 @@ mod tests {
let error = migrate_sqlite_ticket_schema(&connection).unwrap_err();
assert!(error.to_string().contains(
"migration history must contain only the canonical version 7 baseline marker"
"migration history must contain only the canonical version 9 baseline marker"
));
assert!(!table_exists(&connection, "typed_tickets").unwrap());
}
+244 -617
View File
File diff suppressed because it is too large Load Diff
+800
View File
@@ -0,0 +1,800 @@
//! Public Ticket case authority contracts over isolated SQLite stores.
use rusqlite::{Connection, params};
use std::collections::BTreeMap;
use std::sync::{Arc, Barrier};
use tempfile::TempDir;
use ticket::*;
fn backend(temp: &TempDir) -> SqliteTicketBackend {
SqliteTicketBackend::open(temp.path().join("tickets.db"), "workspace").unwrap()
}
fn request(ticket: &Ticket, key: &str, action: TicketCaseAction) -> TicketCaseUpdate {
TicketCaseUpdate {
expected_content_digest: ticket_content_digest(ticket),
expected_state: ticket.meta.workflow_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: key.into(),
action,
explanation: "A reasoned judgment.\n\nNot an execution milestone.".into(),
references: vec![TicketReference {
kind: "result".into(),
target: "document:example".into(),
}],
author: Some("human:alice".into()),
}
}
fn close(reason: TicketClosureReason) -> TicketCaseAction {
TicketCaseAction::Close { reason }
}
fn create(backend: &SqliteTicketBackend) -> Ticket {
let mut input = NewTicket::new("Independent case");
input.assignee = Some("historical-assignee".into());
backend.create_with_snapshot(input).unwrap().1
}
#[test]
fn close_reopen_and_correction_preserve_reason_actor_references_and_identity() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::NotPlanned)),
)
.unwrap();
assert!(closed.meta.targets.is_empty());
assert_eq!(closed.meta.workflow_state, TicketState::Closed);
let first_judgment = closed.current_closure.clone().unwrap();
assert_eq!(first_judgment.reason, TicketClosureReason::NotPlanned);
assert_eq!(first_judgment.author, "human:alice");
assert_eq!(
first_judgment.references,
request(&initial, "close", close(TicketClosureReason::NotPlanned)).references
);
let corrected = store
.update_case(
&initial.meta.id,
request(
&closed,
"correct",
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::Duplicate,
},
),
)
.unwrap();
assert_eq!(
corrected.current_closure.as_ref().unwrap().reason,
TicketClosureReason::Duplicate
);
assert_eq!(corrected.case_history[0], closed.case_history[0]);
assert_eq!(
corrected.case_history[1]
.previous_closure_operation_key
.as_deref(),
Some("close")
);
let reopened = store
.update_case(
&initial.meta.id,
request(&corrected, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
assert_eq!(reopened.meta.workflow_state, TicketState::Open);
assert!(reopened.current_closure.is_none());
assert!(reopened.resolution.is_none());
assert_eq!(reopened.closure_operation_key.as_deref(), Some("correct"));
assert_eq!(reopened.case_history.len(), 3);
assert_eq!(reopened.meta.assignee, initial.meta.assignee);
assert_eq!(reopened.meta.resource_key, initial.meta.resource_key);
assert_eq!(
&reopened.events[..initial.events.len()],
initial.events.as_slice()
);
assert_eq!(
store.show(initial.meta.id.clone().into()).unwrap(),
reopened
);
}
#[test]
fn closure_reasons_round_trip_without_repository_or_merge_request_prerequisites() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
for reason in [
TicketClosureReason::Completed,
TicketClosureReason::NotPlanned,
TicketClosureReason::Duplicate,
TicketClosureReason::Unknown,
TicketClosureReason::Other("Custom decision".into()),
] {
let initial = create(&store);
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(reason.clone())),
)
.unwrap();
assert_eq!(closed.current_closure.unwrap().reason, reason);
}
}
#[test]
fn case_cas_rejects_stale_content_state_and_closure_key_without_writes() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let base = request(&initial, "close", close(TicketClosureReason::Completed));
let mut stale = base.clone();
stale.expected_content_digest = "stale".into();
assert!(matches!(
store.update_case(&initial.meta.id, stale),
Err(TicketError::StaleContent { .. })
));
let mut stale = base.clone();
stale.expected_state = TicketState::Closed;
assert!(matches!(
store.update_case(&initial.meta.id, stale),
Err(TicketError::StaleWorkflowState { .. })
));
let mut stale = base;
stale.expected_closure_operation_key = Some("unobserved".into());
assert!(matches!(
store.update_case(&initial.meta.id, stale),
Err(TicketError::StaleClosureOperationKey { .. })
));
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
}
#[test]
fn closure_keys_protect_closed_correction_and_open_closed_open_aba() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let stale_open = request(
&initial,
"late-close",
close(TicketClosureReason::Completed),
);
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::Unknown)),
)
.unwrap();
let stale_correction = request(
&closed,
"late-correct",
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::Completed,
},
);
let corrected = store
.update_case(
&initial.meta.id,
request(
&closed,
"correct",
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::NotPlanned,
},
),
)
.unwrap();
assert!(matches!(
store.update_case(&initial.meta.id, stale_correction),
Err(TicketError::StaleClosureOperationKey { .. })
));
let reopened = store
.update_case(
&initial.meta.id,
request(&corrected, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
assert_eq!(
ticket_content_digest(&reopened),
ticket_content_digest(&initial)
);
assert!(matches!(
store.update_case(&initial.meta.id, stale_open),
Err(TicketError::StaleClosureOperationKey { .. })
));
assert_eq!(
store.show(initial.meta.id.clone().into()).unwrap(),
reopened
);
}
#[test]
fn exact_replay_after_edit_reopen_and_backend_restart_does_not_reapply_a_close() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
let closed = store
.update_case(&initial.meta.id, operation.clone())
.unwrap();
let reopened = store
.update_case(
&initial.meta.id,
request(&closed, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
let edited = store
.edit_item(
initial.meta.id.clone().into(),
TicketItemEdit {
title: Some("Changed requirements".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(edited.case_history, reopened.case_history);
let restored =
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "workspace").unwrap();
assert_eq!(
restored.update_case(&initial.meta.id, operation).unwrap(),
edited
);
}
#[test]
fn replay_fingerprint_binds_every_request_field_and_authenticated_source_identity() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
let closed = store
.update_case(&initial.meta.id, operation.clone())
.unwrap();
let mut changed = Vec::new();
let mut x = operation.clone();
x.expected_content_digest.push('x');
changed.push(x);
let mut x = operation.clone();
x.expected_state = TicketState::Closed;
changed.push(x);
let mut x = operation.clone();
x.expected_closure_operation_key = Some("close".into());
changed.push(x);
let mut x = operation.clone();
x.action = close(TicketClosureReason::NotPlanned);
changed.push(x);
let mut x = operation.clone();
x.action = TicketCaseAction::Reopen;
changed.push(x);
let mut x = operation.clone();
x.explanation.push('x');
changed.push(x);
let mut x = operation.clone();
x.references.clear();
changed.push(x);
let mut x = operation.clone();
x.author = None;
changed.push(x);
for input in changed {
assert!(matches!(
store.update_case(&initial.meta.id, input),
Err(TicketError::OperationFingerprintMismatch { .. })
));
}
let other_source = store.clone().with_event_attributes(BTreeMap::from([(
"source_runtime_id".into(),
"other-runtime".into(),
)]));
assert!(matches!(
other_source.update_case(&initial.meta.id, operation),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
}
#[test]
fn invalid_action_does_not_close_correct_or_reopen_the_wrong_case() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
for action in [
TicketCaseAction::Reopen,
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::Completed,
},
] {
assert!(
store
.update_case(&initial.meta.id, request(&initial, "invalid", action))
.is_err()
);
}
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::Unknown)),
)
.unwrap();
assert!(
store
.update_case(
&initial.meta.id,
request(
&closed,
"double-close",
close(TicketClosureReason::Completed)
)
)
.is_err()
);
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
}
#[test]
fn custom_reason_limits_are_utf8_bytes_and_validation_keeps_long_markdown_explanations() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
for reason in [
"".into(),
" \n".into(),
"bad\0reason".into(),
"あ".repeat(342),
] {
assert!(
store
.update_case(
&initial.meta.id,
request(&initial, "bad", close(TicketClosureReason::Other(reason)))
)
.is_err()
);
}
let mut valid = request(
&initial,
"close",
close(TicketClosureReason::Other("x".repeat(1024))),
);
valid.explanation = format!(
"## Decision\n{}\n<!-- typed SQLite text -->",
"long".repeat(8000)
);
let closed = store.update_case(&initial.meta.id, valid.clone()).unwrap();
assert_eq!(
closed.current_closure.as_ref().unwrap().explanation,
valid.explanation
);
assert_eq!(store.update_case(&initial.meta.id, valid).unwrap(), closed);
}
#[test]
fn blank_explanation_actor_reference_or_key_rejects_without_recording_receipts() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let base = request(&initial, "key", close(TicketClosureReason::Unknown));
let mut cases = Vec::new();
let mut x = base.clone();
x.explanation = " ".into();
cases.push(x);
let mut x = base.clone();
x.explanation = "bad\0text".into();
cases.push(x);
let mut x = base.clone();
x.author = Some(" ".into());
cases.push(x);
let mut x = base.clone();
x.references[0].target.clear();
cases.push(x);
let mut x = base.clone();
x.references[0].kind.clear();
cases.push(x);
let mut x = base;
x.operation_key.clear();
cases.push(x);
for input in cases {
assert!(store.update_case(&initial.meta.id, input).is_err());
}
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
}
#[test]
fn case_history_is_immutable_in_sqlite_and_hook_failure_rolls_back_every_case_write() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let rejected = store.clone().with_mutation_hook(Arc::new(|_, _| {
Err(TicketError::Conflict("hook rejected".into()))
}));
let operation = request(&initial, "close", close(TicketClosureReason::Unknown));
assert!(
rejected
.update_case(&initial.meta.id, operation.clone())
.is_err()
);
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
let closed = store.update_case(&initial.meta.id, operation).unwrap();
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
for sql in [
"UPDATE typed_ticket_case_history SET judgment_json = '{}'",
"DELETE FROM typed_ticket_case_history",
] {
assert!(conn.execute(sql, []).is_err());
}
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
}
#[test]
fn concurrent_exact_replay_creates_one_judgment_and_competing_closes_have_one_winner() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let barrier = Arc::new(Barrier::new(2));
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
let mut joins = Vec::new();
for _ in 0..2 {
let store = store.clone();
let barrier = barrier.clone();
let operation = operation.clone();
let id = initial.meta.id.clone();
joins.push(std::thread::spawn(move || {
barrier.wait();
store.update_case(&id, operation)
}));
}
for join in joins {
join.join().unwrap().unwrap();
}
assert_eq!(
store
.show(initial.meta.id.clone().into())
.unwrap()
.case_history
.len(),
1
);
let initial = create(&store);
let barrier = Arc::new(Barrier::new(2));
let mut joins = Vec::new();
for key in ["winner-a", "winner-b"] {
let store = store.clone();
let barrier = barrier.clone();
let operation = request(&initial, key, close(TicketClosureReason::Unknown));
let id = initial.meta.id.clone();
joins.push(std::thread::spawn(move || {
barrier.wait();
store.update_case(&id, operation)
}));
}
let outcomes: Vec<_> = joins.into_iter().map(|j| j.join().unwrap()).collect();
assert_eq!(outcomes.iter().filter(|result| result.is_ok()).count(), 1);
assert!(
outcomes
.iter()
.any(|result| matches!(result, Err(TicketError::StaleWorkflowState { .. })))
);
assert_eq!(
store
.show(initial.meta.id.clone().into())
.unwrap()
.case_history
.len(),
1
);
}
#[test]
fn queue_and_readiness_are_routing_audit_and_never_change_case_or_reopen_closed() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let ready = store
.mark_ready(
initial.meta.id.clone().into(),
TicketMarkReady {
operation_key: "ready".into(),
reason: None,
intake_summary: None,
author: None,
},
)
.unwrap();
assert_eq!(ready.meta.workflow_state, TicketState::Open);
assert_eq!(ready.meta.readiness.as_deref(), Some("ready"));
store
.queue_ready(initial.meta.id.clone().into(), "router")
.unwrap();
let queued = store.show(initial.meta.id.clone().into()).unwrap();
assert_eq!(queued.meta.workflow_state, TicketState::Open);
assert_eq!(queued.meta.queued_by.as_deref(), Some("router"));
let closed = store
.update_case(
&initial.meta.id,
request(&queued, "close", close(TicketClosureReason::NotPlanned)),
)
.unwrap();
assert!(
store
.queue_ready(initial.meta.id.clone().into(), "router")
.is_err()
);
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), closed);
}
#[test]
fn workspace_boundary_rejects_other_workspace_ticket_and_shared_operation_dispatch_uses_case_api() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let other =
SqliteTicketBackend::open_verified(temp.path().join("tickets.db"), "other-workspace")
.unwrap();
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
assert!(matches!(
other.update_case(&initial.meta.id, operation.clone()),
Err(TicketError::NotFound(_))
));
let output = execute_ticket_backend_operation(
&store,
TicketBackendOperation::UpdateCase {
ticket: initial.meta.id.clone(),
request: operation,
},
)
.unwrap();
assert!(
matches!(output, TicketBackendOperationResult::Ticket(ticket) if ticket.meta.workflow_state == TicketState::Closed)
);
}
#[test]
fn historical_events_and_merge_request_evidence_are_not_rewritten_by_case_judgments() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let historical = store.clone().with_event_attributes(BTreeMap::from([
("operation_key".into(), "old-completion".into()),
("merge_request_id".into(), "MR-existing".into()),
]));
let mut event = NewTicketEvent::new(
TicketEventKind::Other("ticket_completion".into()),
"Historic completed result",
);
event.references = vec![TicketReference {
kind: "merge_request".into(),
target: "MR-existing".into(),
}];
historical
.add_event(initial.meta.id.clone().into(), event)
.unwrap();
let before = store.show(initial.meta.id.clone().into()).unwrap();
let after = store
.update_case(
&initial.meta.id,
request(&before, "new-close", close(TicketClosureReason::NotPlanned)),
)
.unwrap();
assert_eq!(
&after.events[..before.events.len()],
before.events.as_slice()
);
assert!(
store
.update_case(
&initial.meta.id,
request(
&before,
"old-completion",
close(TicketClosureReason::Completed)
)
)
.is_err()
);
}
#[test]
fn external_case_json_rejects_six_state_authority_and_round_trips_typed_action() {
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"in_progress",
] {
assert!(serde_json::from_value::<TicketState>(serde_json::json!(state)).is_err());
}
let encoded = serde_json::json!({"action": "correct_closure", "reason": {"other": "Custom"}});
let action: TicketCaseAction = serde_json::from_value(encoded.clone()).unwrap();
assert_eq!(serde_json::to_value(action).unwrap(), encoded);
assert!(
serde_json::from_value::<TicketCaseAction>(
serde_json::json!({"action": "reopen", "reason": "completed"})
)
.is_err()
);
}
fn assignment_source() -> BTreeMap<String, String> {
BTreeMap::from([
("source_runtime_id".into(), "runtime-a".into()),
("source_worker_id".into(), "worker-a".into()),
("source_actor_role".into(), "ticket_worker".into()),
("source_assignment_id".into(), "assignment-a".into()),
])
}
fn assignment_fixture(temp: &TempDir, id: &str) -> Connection {
let conn = Connection::open(temp.path().join("tickets.db")).unwrap();
conn.execute_batch("CREATE TABLE test_worker_assignments (workspace_id TEXT, ticket_id TEXT, runtime_id TEXT, worker_id TEXT, assignment_id TEXT, status TEXT);
CREATE VIEW ticket_active_worker_assignments AS SELECT workspace_id, ticket_id, runtime_id, worker_id, assignment_id FROM test_worker_assignments WHERE status = 'active';").unwrap();
conn.execute("INSERT INTO test_worker_assignments VALUES ('workspace', ?1, 'runtime-a', 'worker-a', 'assignment-a', 'active')", params![id]).unwrap();
conn
}
#[test]
fn assigned_worker_requires_exact_live_identity_but_recorded_replay_survives_release() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let conn = assignment_fixture(&temp, &initial.meta.id);
let operation = request(&initial, "close", close(TicketClosureReason::Completed));
for key in [
"source_runtime_id",
"source_worker_id",
"source_assignment_id",
"source_actor_role",
] {
let mut wrong = assignment_source();
wrong.insert(key.into(), "mismatch".into());
assert!(
store
.clone()
.with_event_attributes(wrong)
.update_case(&initial.meta.id, operation.clone())
.is_err()
);
}
for key in [
"source_runtime_id",
"source_worker_id",
"source_assignment_id",
] {
let mut missing = assignment_source();
missing.remove(key);
assert!(
store
.clone()
.with_event_attributes(missing)
.update_case(&initial.meta.id, operation.clone())
.is_err()
);
}
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
let source = store.clone().with_event_attributes(assignment_source());
let closed = source
.update_case(&initial.meta.id, operation.clone())
.unwrap();
// The Ticket domain does not itself release or stop workers. Parent Workspace triggers own this.
assert_eq!(
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
.get::<_, String>(
0
))
.unwrap(),
"active"
);
conn.execute("UPDATE test_worker_assignments SET status = 'released'", [])
.unwrap();
let reopened = store
.update_case(
&initial.meta.id,
request(&closed, "reopen", TicketCaseAction::Reopen),
)
.unwrap();
assert_eq!(
source.update_case(&initial.meta.id, operation).unwrap(),
reopened
);
assert!(
source
.update_case(
&initial.meta.id,
request(&reopened, "new-close", close(TicketClosureReason::Unknown))
)
.is_err()
);
assert_eq!(
conn.query_row("SELECT status FROM test_worker_assignments", [], |r| r
.get::<_, String>(
0
))
.unwrap(),
"released"
);
}
#[test]
fn content_digest_changes_with_targets_or_requirements_not_case_judgment_history() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let digest = ticket_content_digest(&initial);
let closed = store
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::Unknown)),
)
.unwrap();
assert_eq!(ticket_content_digest(&closed), digest);
let edited = store
.edit_item(
initial.meta.id.clone().into(),
TicketItemEdit {
body: Some(MarkdownText::new("New criteria")),
..Default::default()
},
)
.unwrap();
assert_ne!(ticket_content_digest(&edited), digest);
let restored = store
.edit_item(
initial.meta.id.clone().into(),
TicketItemEdit {
body: Some(initial.document.body.clone()),
..Default::default()
},
)
.unwrap();
assert_eq!(ticket_content_digest(&restored), digest);
let targeted = store
.edit_item(
initial.meta.id.clone().into(),
TicketItemEdit {
targets: Some(TicketTargetsEdit::Set {
targets: vec![TicketTarget {
repository_key: "main".into(),
ref_selector: None,
access: TicketTargetAccess::ReadOnly,
}],
}),
..Default::default()
},
)
.unwrap();
assert_ne!(ticket_content_digest(&targeted), digest);
}
#[test]
fn external_case_request_requires_an_explicit_nullable_closure_observation() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let original = request(&initial, "close", close(TicketClosureReason::Unknown));
let mut json = serde_json::to_value(&original).unwrap();
assert_eq!(
serde_json::from_value::<TicketCaseUpdate>(json.clone()).unwrap(),
original
);
json.as_object_mut()
.unwrap()
.remove("expected_closure_operation_key");
assert!(serde_json::from_value::<TicketCaseUpdate>(json).is_err());
}
#[test]
fn conflicting_event_context_rolls_back_case_judgment_instead_of_corrupting_its_receipt() {
let temp = TempDir::new().unwrap();
let store = backend(&temp);
let initial = create(&store);
let context = store.clone().with_event_attributes(BTreeMap::from([(
"operation_key".into(),
"wrong-key".into(),
)]));
assert!(
context
.update_case(
&initial.meta.id,
request(&initial, "close", close(TicketClosureReason::Unknown))
)
.is_err()
);
assert_eq!(store.show(initial.meta.id.clone().into()).unwrap(), initial);
}
+134
View File
@@ -0,0 +1,134 @@
-- Frozen standalone schema7 fixture; deliberate persisted compatibility contract.
-- Canonical standalone Ticket schema. Workspace Server composes stricter cross-domain authority.
CREATE TABLE typed_ticket_artifacts (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, relative_path TEXT NOT NULL, content BLOB NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, relative_path),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_event_attributes (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, key),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_event_references (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, event_index INTEGER NOT NULL, ordinal INTEGER NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index, ordinal),
FOREIGN KEY (workspace_id, ticket_id, event_index) REFERENCES typed_ticket_events(workspace_id, ticket_id, event_index) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_events (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
event_index INTEGER NOT NULL,
kind TEXT NOT NULL,
author TEXT,
at TEXT,
status TEXT,
from_state TEXT,
to_state TEXT,
reason TEXT,
state_field TEXT,
heading TEXT,
body TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, event_index),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_labels (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, label TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_orchestration_plans (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
record_id TEXT NOT NULL,
kind TEXT NOT NULL,
related_ticket TEXT,
note TEXT,
accepted_summary TEXT,
accepted_branch TEXT,
accepted_worktree TEXT,
accepted_role_plan TEXT,
author TEXT NOT NULL,
at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, record_id),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_raw_frontmatter (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, key TEXT NOT NULL, value TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_relations (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, kind TEXT NOT NULL, target TEXT NOT NULL, note TEXT, author TEXT NOT NULL, at TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, kind, target),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_ticket_risk_flags (
workspace_id TEXT NOT NULL, ticket_id TEXT NOT NULL, ordinal INTEGER NOT NULL, risk_flag TEXT NOT NULL,
PRIMARY KEY (workspace_id, ticket_id, ordinal),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE typed_tickets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
slug TEXT NOT NULL,
title TEXT NOT NULL,
status TEXT NOT NULL,
kind TEXT NOT NULL,
priority TEXT NOT NULL,
body TEXT NOT NULL,
created_at TEXT,
updated_at TEXT,
assignee TEXT,
readiness TEXT,
workflow_state TEXT NOT NULL,
workflow_state_explicit INTEGER NOT NULL,
queued_by TEXT,
queued_at TEXT,
resolution TEXT,
PRIMARY KEY (workspace_id, ticket_id)
);
CREATE TABLE typed_ticket_targets (
workspace_id TEXT NOT NULL,
ticket_id TEXT NOT NULL,
ordinal INTEGER NOT NULL,
repository_key TEXT NOT NULL,
ref_selector TEXT,
access TEXT NOT NULL CHECK (access IN ('read_only', 'read_write')),
PRIMARY KEY (workspace_id, ticket_id, ordinal),
UNIQUE (workspace_id, ticket_id, repository_key),
FOREIGN KEY (workspace_id, ticket_id) REFERENCES typed_tickets(workspace_id, ticket_id) ON DELETE CASCADE
);
CREATE TABLE "workspace_resource_key_counters" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
next_sequence INTEGER NOT NULL CHECK (next_sequence > 0),
PRIMARY KEY (workspace_id, resource_kind)
);
CREATE TABLE "workspace_resource_keys" (
workspace_id TEXT NOT NULL,
resource_kind TEXT NOT NULL CHECK (resource_kind IN ('ticket', 'objective', 'worker')),
resource_id TEXT NOT NULL,
sequence INTEGER NOT NULL CHECK (sequence > 0),
resource_key TEXT NOT NULL,
allocated_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, resource_kind, resource_id),
UNIQUE (workspace_id, resource_kind, sequence),
UNIQUE (workspace_id, resource_key)
);
CREATE INDEX idx_workspace_resource_keys_reverse
ON workspace_resource_keys(workspace_id, resource_kind, resource_key);
CREATE INDEX typed_ticket_events_workspace_kind_ticket
ON typed_ticket_events(workspace_id, kind, ticket_id, event_index);
CREATE INDEX typed_ticket_relations_workspace_source_kind
ON typed_ticket_relations(workspace_id, ticket_id, kind, target);
CREATE INDEX typed_ticket_relations_workspace_target_kind
ON typed_ticket_relations(workspace_id, target, kind, ticket_id);
CREATE INDEX typed_tickets_workspace_created
ON typed_tickets(workspace_id, created_at DESC, ticket_id);
CREATE INDEX typed_tickets_workspace_state_updated
ON typed_tickets(workspace_id, workflow_state, updated_at DESC, ticket_id);
CREATE INDEX typed_tickets_workspace_title
ON typed_tickets(workspace_id, title COLLATE NOCASE, ticket_id);
CREATE INDEX typed_tickets_workspace_updated
ON typed_tickets(workspace_id, updated_at DESC, ticket_id);
+252
View File
@@ -0,0 +1,252 @@
//! Adopted compatibility contract: canonical standalone schema7 six-state snapshots
//! migrate independently to schema9, preserving original evidence and routing handoff.
use rusqlite::{Connection, params};
use tempfile::TempDir;
use ticket::*;
fn schema7(connection: &Connection) {
connection
.execute_batch(include_str!("fixtures/schema7.sql"))
.unwrap();
connection.execute_batch("CREATE TABLE ticket_schema_migrations (version INTEGER PRIMARY KEY, name TEXT NOT NULL, applied_at TEXT NOT NULL);
INSERT INTO ticket_schema_migrations VALUES (7, 'ticket schema baseline', 'legacy');").unwrap();
}
#[test]
fn standalone_schema7_migration_preserves_six_state_snapshot_and_evidence_without_success_inference()
{
let temp = TempDir::new().unwrap();
let path = temp.path().join("tickets.db");
let connection = Connection::open(&path).unwrap();
schema7(&connection);
connection
.execute_batch("CREATE TABLE legacy_worker_status (ticket_id TEXT, status TEXT);")
.unwrap();
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit, assignee, queued_by, queued_at, resolution) VALUES ('workspace', ?1, ?1, ?1, 'open', 'task', 'P2', 'criteria', ?1, 1, 'retained-assignee', 'legacy-router', 'legacy-queue-time', 'Original decision text')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_events (workspace_id, ticket_id, event_index, kind, from_state, to_state, reason, author, body) VALUES ('workspace', ?1, 0, 'state_changed', 'planning', ?1, 'original reason', 'legacy-actor', 'legacy body')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'operation_key', 'legacy-receipt')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'request_fingerprint', 'legacy-fingerprint')", params![state]).unwrap();
// Actual pre-content receipt spelling: preserve it as historical input,
// never promote an old counter fingerprint into a current content CAS.
connection.execute("INSERT INTO typed_ticket_event_attributes VALUES ('workspace', ?1, 0, 'expected_content_digest', 'old-input')", params![state]).unwrap();
connection.execute(
"UPDATE typed_ticket_event_attributes SET key='expected_item_revision',value='T:0' WHERE ticket_id=?1 AND key='expected_content_digest'",
[state],
).unwrap();
connection.execute("INSERT INTO typed_ticket_event_references VALUES ('workspace', ?1, 0, 0, 'merge_request', 'MR-existing')", params![state]).unwrap();
connection.execute("INSERT INTO typed_ticket_targets VALUES ('workspace', ?1, 0, 'main', 'develop', 'read_write')", params![state]).unwrap();
connection
.execute(
"INSERT INTO legacy_worker_status VALUES (?1, 'released')",
params![state],
)
.unwrap();
}
// Snapshot evidence before migration, including untyped `done` and existing MR references.
let historical_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
let old_attributes: Vec<(String, String, String)> = connection
.prepare(
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
)
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap();
migrate_sqlite_ticket_schema(&connection).unwrap();
let preserved_attributes: Vec<(String, String, String)> = connection
.prepare(
"SELECT ticket_id,key,value FROM typed_ticket_event_attributes ORDER BY ticket_id,key",
)
.unwrap()
.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))
.unwrap()
.collect::<std::result::Result<_, _>>()
.unwrap();
assert_eq!(old_attributes, preserved_attributes);
verify_sqlite_ticket_schema(&connection).unwrap();
let marker: i64 = connection
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
row.get(0)
})
.unwrap();
assert_eq!(marker, 9);
let after_events: Vec<_> = connection.prepare("SELECT ticket_id, from_state, to_state, reason, author, body FROM typed_ticket_events ORDER BY ticket_id").unwrap().query_map([], |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, String>(2)?, row.get::<_, String>(3)?, row.get::<_, String>(4)?, row.get::<_, String>(5)?))).unwrap().collect::<std::result::Result<_, _>>().unwrap();
assert_eq!(after_events, historical_events);
let store = SqliteTicketBackend::open_verified(&path, "workspace").unwrap();
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
let ticket = store.show(state.into()).unwrap();
let expected_state = if matches!(state, "done" | "closed") {
TicketState::Closed
} else {
TicketState::Open
};
assert_eq!(ticket.meta.workflow_state, expected_state);
let old_key_request = TicketCaseUpdate {
expected_content_digest: ticket_content_digest(&ticket),
expected_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: "legacy-receipt".into(),
action: if expected_state == TicketState::Closed {
TicketCaseAction::CorrectClosure {
reason: TicketClosureReason::Unknown,
}
} else {
TicketCaseAction::Close {
reason: TicketClosureReason::Unknown,
}
},
explanation: "Cannot reinterpret a frozen operation receipt".into(),
references: vec![],
author: Some("human".into()),
};
assert!(matches!(
store.update_case(state, old_key_request),
Err(TicketError::OperationFingerprintMismatch { .. })
));
assert_eq!(store.show(state.into()).unwrap(), ticket);
let legacy = ticket.legacy_workflow_snapshot.as_ref().unwrap();
assert_eq!(legacy.workflow_state, state);
assert!(legacy.workflow_state_explicit);
assert_eq!(legacy.status, "open");
assert_eq!(legacy.queued_by.as_deref(), Some("legacy-router"));
assert_eq!(legacy.queued_at.as_deref(), Some("legacy-queue-time"));
assert_eq!(legacy.resolution.as_deref(), Some("Original decision text"));
assert_eq!(ticket.meta.assignee.as_deref(), Some("retained-assignee"));
assert_eq!(ticket.meta.targets[0].repository_key, "main");
assert_eq!(ticket.events[0].from.as_deref(), Some("planning"));
assert_eq!(ticket.events[0].to.as_deref(), Some(state));
assert_eq!(ticket.events[0].references[0].target, "MR-existing");
assert_eq!(
ticket.events[0].attributes["request_fingerprint"],
"legacy-fingerprint"
);
if expected_state == TicketState::Closed {
let judgment = ticket.current_closure.as_ref().unwrap();
assert_eq!(
judgment.reason,
TicketClosureReason::Unknown,
"old done must never mean successful completion"
);
assert_eq!(judgment.explanation, "Original decision text");
let reopened = store
.update_case(
state,
TicketCaseUpdate {
expected_content_digest: ticket_content_digest(&ticket),
expected_state,
expected_closure_operation_key: ticket.closure_operation_key.clone(),
operation_key: "reopen-migrated".into(),
action: TicketCaseAction::Reopen,
explanation: "Reconsider the case".into(),
references: Vec::new(),
author: Some("human".into()),
},
)
.unwrap();
assert!(reopened.current_closure.is_none());
assert_eq!(reopened.case_history[0], ticket.case_history[0]);
assert_eq!(
reopened.legacy_workflow_snapshot,
ticket.legacy_workflow_snapshot
);
} else {
assert!(ticket.current_closure.is_none());
}
}
let released: i64 = connection
.query_row(
"SELECT COUNT(*) FROM legacy_worker_status WHERE status = 'released'",
[],
|row| row.get(0),
)
.unwrap();
assert_eq!(released, 6);
assert!(
connection
.execute(
"UPDATE typed_ticket_legacy_workflow_snapshot SET snapshot_json = '{}'",
[]
)
.is_err()
);
migrate_sqlite_ticket_schema(&connection).unwrap();
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM typed_ticket_legacy_workflow_snapshot",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
6
);
}
#[test]
fn unknown_legacy_state_rolls_back_the_independent_case_migration() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
connection.execute("INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) VALUES ('workspace', 'unknown', 'unknown', 'unknown', 'open', 'task', 'P2', 'body', 'future-state', 1)", []).unwrap();
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
assert_eq!(
connection
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
7
);
assert_eq!(
connection
.query_row("SELECT workflow_state FROM typed_tickets", [], |row| row
.get::<_, String>(
0
))
.unwrap(),
"future-state"
);
assert_eq!(
connection
.query_row(
"SELECT COUNT(*) FROM sqlite_master WHERE name = 'typed_ticket_case_history'",
[],
|row| row.get::<_, i64>(0)
)
.unwrap(),
0
);
}
#[test]
fn schema8_tag_composition_is_rejected_until_explicitly_integrated() {
let connection = Connection::open_in_memory().unwrap();
schema7(&connection);
connection
.execute("UPDATE ticket_schema_migrations SET version = 8", [])
.unwrap();
assert!(migrate_sqlite_ticket_schema(&connection).is_err());
assert_eq!(
connection
.query_row("SELECT version FROM ticket_schema_migrations", [], |row| {
row.get::<_, i64>(0)
})
.unwrap(),
8
);
}
@@ -247,13 +247,13 @@ fn validate_relative_cwd(value: String) -> Result<String, ToolError> {
mod tests {
use std::sync::Mutex;
use ticket::{TicketError, TicketWorkflowState};
use ticket::{TicketError, TicketState};
use super::*;
use crate::feature::builtin::ticket::TicketHandoff;
use crate::worker::{WorkspaceClientError, WorkspaceResponse};
struct FixedTicketService(TicketWorkflowState);
struct FixedTicketService(TicketState);
impl TicketService for FixedTicketService {
fn ticket_handoff(&self, ticket_ref: &str) -> Result<TicketHandoff, TicketError> {
@@ -295,7 +295,7 @@ mod tests {
})
}
fn tool(service: Arc<RecordingService>, state: TicketWorkflowState) -> SpawnTicketWorkerTool {
fn tool(service: Arc<RecordingService>, state: TicketState) -> SpawnTicketWorkerTool {
SpawnTicketWorkerTool {
ticket_service: Arc::new(FixedTicketService(state)),
worker_service: service,
@@ -315,7 +315,7 @@ mod tests {
},
{"alias": "docs", "working_directory_id": "workdir-2"}
]);
let output = tool(service.clone(), TicketWorkflowState::Queued)
let output = tool(service.clone(), TicketState::Open)
.execute(
&input.to_string(),
ToolExecutionContext::new("call-7", "batch-1", 0),
@@ -372,7 +372,7 @@ mod tests {
input["workdir_attachments"] = serde_json::json!([]);
input["flow"] = serde_json::Value::Null;
}
tool(service.clone(), TicketWorkflowState::Queued)
tool(service.clone(), TicketState::Open)
.execute(
&input.to_string(),
ToolExecutionContext::new("call-empty", "batch-1", 0),
@@ -395,7 +395,7 @@ mod tests {
async fn spawn_ticket_worker_omitted_profile_uses_general_worker_without_flow_or_repository() {
let service = Arc::new(RecordingService::default());
let definition = definition(
Arc::new(FixedTicketService(TicketWorkflowState::Ready)),
Arc::new(FixedTicketService(TicketState::Open)),
service.clone(),
);
let (meta, tool) = definition();
@@ -434,33 +434,35 @@ mod tests {
}
#[tokio::test]
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend() {
let service = Arc::new(RecordingService {
response: Some(WorkspaceResponse {
status: 409,
body: "Backend rejected Ticket claim".into(),
}),
..Default::default()
});
let error = tool(service.clone(), TicketWorkflowState::Planning)
.execute(
&input().to_string(),
ToolExecutionContext::new("call-claim", "batch-1", 0),
)
.await
.unwrap_err();
assert_eq!(service.requests.lock().unwrap().len(), 1);
assert!(
error
.to_string()
.contains("HTTP 409: Backend rejected Ticket claim")
);
async fn spawn_ticket_worker_defers_claim_eligibility_to_backend_not_case_state() {
for state in [TicketState::Open, TicketState::Closed] {
let service = Arc::new(RecordingService {
response: Some(WorkspaceResponse {
status: 409,
body: "Backend rejected Ticket claim".into(),
}),
..Default::default()
});
let error = tool(service.clone(), state)
.execute(
&input().to_string(),
ToolExecutionContext::new("call-claim", "batch-1", 0),
)
.await
.unwrap_err();
assert_eq!(service.requests.lock().unwrap().len(), 1);
assert!(
error
.to_string()
.contains("HTTP 409: Backend rejected Ticket claim")
);
}
}
#[tokio::test]
async fn spawn_ticket_worker_rejects_invalid_input_before_worker_side_effect() {
let service = Arc::new(RecordingService::default());
let tool = tool(service.clone(), TicketWorkflowState::Queued);
let tool = tool(service.clone(), TicketState::Open);
let cases = [
(
"profile",
@@ -603,7 +605,7 @@ mod tests {
#[test]
fn spawn_ticket_worker_schema_exposes_configuration_not_claim_or_review_grants() {
let definition = definition(
Arc::new(FixedTicketService(TicketWorkflowState::Queued)),
Arc::new(FixedTicketService(TicketState::Open)),
Arc::new(RecordingService::default()),
);
let (meta, _) = definition();
@@ -13,7 +13,10 @@ pub(super) struct ModelTicketQueryResponse {
struct ModelTicketQueryItem {
ticket: String,
title: String,
state: String,
state: ticket::TicketState,
closure_operation_key: Option<String>,
current_closure: Option<ticket::TicketClosureJudgment>,
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
@@ -38,11 +41,15 @@ pub(super) struct ModelTicketDetail {
content_digest: String,
title: String,
body: String,
state: String,
state: ticket::TicketState,
closure_operation_key: Option<String>,
current_closure: Option<ticket::TicketClosureJudgment>,
legacy_handoff: Option<ticket::TicketLegacyWorkflowSnapshot>,
readiness: Option<String>,
priority: Option<String>,
created_at: Option<String>,
updated_at: Option<String>,
closure_history: Vec<ticket::TicketCaseHistoryEntry>,
thread: Vec<ModelTicketEvent>,
relations: ModelTicketRelations,
linked_objectives: Vec<ModelObjectiveSummary>,
@@ -216,6 +223,16 @@ struct ModelObjectiveEventPage {
has_more: bool,
}
fn typed_field<T: serde::de::DeserializeOwned>(
root: &Map<String, Value>,
key: &str,
) -> Result<T, String> {
let value = root
.get(key)
.ok_or_else(|| format!("Ticket projection requires {key}"))?;
serde_json::from_value(value.clone()).map_err(|error| format!("Invalid Ticket {key}: {error}"))
}
pub(super) fn project_ticket_query(value: Value) -> Result<ModelTicketQueryResponse, String> {
let root = object(&value, "Ticket query response")?;
let page = object_field(root, "page")?;
@@ -235,7 +252,10 @@ fn project_ticket_query_item(value: &Value) -> Result<ModelTicketQueryItem, Stri
Ok(ModelTicketQueryItem {
ticket: resource_ref(item, "resource_key", "T-")?,
title: string_field(item, "title")?,
state: string_field(item, "state")?,
state: typed_field(item, "state")?,
closure_operation_key: typed_field(item, "closure_operation_key")?,
current_closure: typed_field(item, "current_closure")?,
legacy_handoff: typed_field(item, "legacy_handoff")?,
readiness: optional_string(item, "readiness")?,
priority: optional_string(item, "priority")?,
created_at: optional_string(item, "created_at")?,
@@ -286,7 +306,11 @@ pub(super) fn project_ticket_detail(value: Value) -> Result<ModelTicketDetail, S
content_digest: string_field(root, "content_digest")?,
title: string_field(root, "title")?,
body: string_field(root, "body")?,
state: string_field(root, "state")?,
state: typed_field(root, "state")?,
closure_operation_key: typed_field(root, "closure_operation_key")?,
current_closure: typed_field(root, "current_closure")?,
legacy_handoff: typed_field(root, "legacy_handoff")?,
closure_history: typed_field(root, "closure_history")?,
readiness: optional_string(root, "readiness")?,
priority: optional_string(root, "priority")?,
created_at: optional_string(root, "created_at")?,
@@ -694,16 +718,10 @@ mod tests {
#[test]
fn mrless_evidence_is_inapplicable_not_missing_or_vacuously_approved() {
for state in [
"planning",
"ready",
"queued",
"inprogress",
"done",
"closed",
] {
for state in ["open", "closed"] {
let projected = project_ticket_detail(json!({
"resource_key": "T-718", "content_digest": "rev-1", "title": "No Git required", "body": "Decision",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"state": state, "events": [{"sequence": 1, "kind": "completed", "body": "No Git needed"}],
"linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null,
@@ -724,6 +742,54 @@ mod tests {
}
}
#[test]
fn reopened_ticket_projection_preserves_closure_observation_history_and_legacy_handoff() {
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"
},
{
"operation_key": "reopen-1", "previous_closure_operation_key": "close-1",
"action": {"action": "reopen"}, "explanation": "New information",
"references": [], "author": "workspace-user:bob", "at": "2026-01-02T00:00:00Z"
}
]);
let legacy = json!({
"workflow_state": "queued", "workflow_state_explicit": true, "status": "open",
"queued_by": "workspace-user:alice", "queued_at": "2025-12-31T00:00:00Z", "resolution": null
});
let response = json!({
"resource_key": "T-739", "content_digest": "content-digest", "title": "Case", "body": "Requirements",
"state": "open", "closure_operation_key": "close-1", "current_closure": null,
"closure_history": history, "legacy_handoff": legacy,
"events": [], "linked_objectives": [], "assignments": [], "implementation_reports": [],
"merge_requests": [], "merge_request": null
});
let projected =
serde_json::to_value(project_ticket_detail(response.clone()).unwrap()).unwrap();
assert_eq!(projected["state"], "open");
assert_eq!(projected["closure_operation_key"], "close-1");
assert_eq!(projected["current_closure"], Value::Null);
assert_eq!(projected["closure_history"], history);
assert_eq!(projected["legacy_handoff"], legacy);
// Missing observations must not silently become null: the model must be
// able to distinguish an observed never-closed case from unknown data.
let mut missing = response;
missing
.as_object_mut()
.unwrap()
.remove("closure_operation_key");
assert!(
project_ticket_detail(missing)
.unwrap_err()
.contains("closure_operation_key")
);
}
#[test]
fn objective_projection_exposes_only_resource_references() {
let projected = project_objective_detail(json!({
@@ -771,7 +837,8 @@ mod tests {
"id": "00001TICKETINTERNAL",
"resource_key": "T-543",
"title": "Ticket",
"state": "inprogress",
"state": "open",
"closure_operation_key": null, "current_closure": null, "legacy_handoff": null,
"readiness": null,
"priority": "high",
"created_at": null,
@@ -842,9 +909,10 @@ mod tests {
"id": "internal-ticket",
"resource_key": "T-588",
"content_digest": "ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Queued Submit",
"body": "Body",
"state": "planning",
"state": "open",
"readiness": null,
"priority": "P2",
"created_at": "2026-09-03T00:00:00Z",
@@ -945,9 +1013,10 @@ mod tests {
let projected = project_ticket_detail(json!({
"resource_key": "T-716",
"content_digest": "current-ticket-content-digest",
"closure_operation_key": null, "current_closure": null, "closure_history": [], "legacy_handoff": null,
"title": "Source evidence",
"body": "Body",
"state": if merged { "done" } else { "inprogress" },
"state": "open",
"events": [],
"linked_objectives": [],
"assignments": [],
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -796,7 +796,7 @@ mod tests {
"ReviewMergeRequest",
"CheckMergeRequestReadiness",
"CompleteMergeRequest",
"CompleteTicket",
"TicketCaseUpdate",
] {
assert!(combined.contains(operation), "missing {operation}");
}
+4 -4
View File
@@ -384,12 +384,12 @@ mod tests {
assert!(!tools.iter().any(|meta| meta.name == "ReviewMergeRequest"));
let complete = tools
.iter()
.find(|meta| meta.name == "CompleteTicket")
.find(|meta| meta.name == "TicketCaseUpdate")
.unwrap();
assert!(
complete
.description
.contains("Merge Requests and approvals are not prerequisites")
.contains("Repository, Merge Request and approval evidence are not prerequisites")
);
assert!(complete.description.contains("not real judgment"));
assert!(
@@ -461,12 +461,12 @@ mod tests {
);
let complete = tools
.iter()
.find(|meta| meta.name == "CompleteTicket")
.find(|meta| meta.name == "TicketCaseUpdate")
.unwrap();
assert!(
complete
.description
.contains("return without conclusion need not call this tool")
.contains("Return without conclusion need not call this tool")
);
}
}
+96 -115
View File
@@ -7,11 +7,11 @@ use merge_request::{
MergeRequest, MergeRequestState, MergeRequestStore, MergeRequestThreadEvent, ReviewDecision,
};
use project_record::{allocate_record_id, unix_epoch_millis_now};
use rusqlite::{params_from_iter, types::Value as SqlValue};
use rusqlite::{OptionalExtension, params, params_from_iter, types::Value as SqlValue};
use ticket::{
SqliteTicketBackend, SqliteTicketListCursor, SqliteTicketListItem, SqliteTicketListPageQuery,
TicketBackend, TicketEvent, TicketIdOrSlug, TicketWorkflowState, TicketWorkspaceActionPriority,
TicketBackend, TicketEvent, TicketIdOrSlug, TicketState, TicketWorkspaceActionPriority,
project_ticket_workspace_item,
};
@@ -282,7 +282,7 @@ impl SqliteWorkspaceAuthority {
id: ticket.id,
resource_key: ticket.resource_key,
title: ticket.title,
state: ticket.state,
state: ticket.state.as_str().into(),
})
.collect::<Vec<_>>();
let resources = self
@@ -387,7 +387,7 @@ impl SqliteWorkspaceAuthority {
let states = query
.states
.iter()
.map(|state| bind(SqlValue::Text(state.clone())))
.map(|state| bind(SqlValue::Text(state.as_str().into())))
.collect::<Vec<_>>();
predicates.push(format!("t.workflow_state IN ({})", states.join(",")));
}
@@ -438,14 +438,7 @@ impl SqliteWorkspaceAuthority {
WHERE relation.workspace_id=t.workspace_id
AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on')
OR (relation.target=t.ticket_id AND relation.kind='blocks'))
AND blocker.workflow_state NOT IN ('done','closed'))";
let active_blocker = "EXISTS (SELECT 1 FROM typed_ticket_relations relation
JOIN typed_tickets blocker ON blocker.workspace_id=relation.workspace_id
AND blocker.ticket_id=CASE WHEN relation.ticket_id=t.ticket_id THEN relation.target ELSE relation.ticket_id END
WHERE relation.workspace_id=t.workspace_id
AND ((relation.ticket_id=t.ticket_id AND relation.kind='depends_on')
OR (relation.target=t.ticket_id AND relation.kind='blocks'))
AND blocker.workflow_state NOT IN ('queued','inprogress','done','closed'))";
AND blocker.workflow_state = 'open')";
if !query.event_kinds.is_empty() {
let event_kinds = query
.event_kinds
@@ -459,17 +452,13 @@ impl SqliteWorkspaceAuthority {
// a primary-MR/latest-request or implementation-report heuristic loses candidates.
for attention in &query.attention {
match attention.as_str() {
"done_not_closed" => predicates.push("t.workflow_state='done'".into()),
"blocked" => predicates.push(blocker.into()),
"unblocked" => predicates.push(format!("NOT {blocker}")),
"ready" => predicates.push(format!("t.workflow_state='ready' AND NOT {blocker}")),
_ => {} // live evidence is evaluated after observation
}
}
let rank_expression = match sort {
TicketQuerySort::Priority => format!(
"CASE WHEN t.workflow_state='ready' AND NOT {active_blocker} THEN 0 WHEN t.workflow_state IN ('queued','inprogress') THEN 1 ELSE 2 END"
),
TicketQuerySort::Priority => "0".to_string(),
TicketQuerySort::Relevance => {
if let Some(text) = query.query.as_deref().filter(|text| !text.is_empty()) {
let pattern = bind(SqlValue::Text(format!("%{}%", text.to_lowercase())));
@@ -807,24 +796,13 @@ impl SqliteWorkspaceAuthority {
}
let action_blockers = Vec::new();
let action_eligibility = TicketActionEligibility {
can_assign_orchestrator: matches!(
ticket.meta.workflow_state,
TicketWorkflowState::Planning | TicketWorkflowState::Ready
) && !has_orchestrator
&& !has_worker,
can_unassign_orchestrator: has_orchestrator
&& matches!(
ticket.meta.workflow_state,
TicketWorkflowState::Planning | TicketWorkflowState::Ready
),
can_queue: true,
can_start_manual_worker: matches!(
ticket.meta.workflow_state,
TicketWorkflowState::Planning
| TicketWorkflowState::Ready
| TicketWorkflowState::Queued
| TicketWorkflowState::InProgress
) && !has_worker
// Case openness is not readiness. These are explicit operations;
// occupancy remains an independent resource constraint.
can_assign_orchestrator: !has_orchestrator && !has_worker,
can_unassign_orchestrator: has_orchestrator,
can_queue: ticket.meta.workflow_state == TicketState::Open,
can_start_manual_worker: ticket.meta.workflow_state == TicketState::Open
&& !has_worker
&& !has_blockers,
queue_tickets: dependency_check.queue_tickets.clone(),
blockers: action_blockers,
@@ -916,12 +894,16 @@ impl SqliteWorkspaceAuthority {
id: ticket.meta.id,
resource_key,
title: ticket.meta.title,
state: ticket.meta.workflow_state.as_str().to_string(),
state: ticket.meta.workflow_state.into(),
readiness: ticket.meta.readiness,
priority: ticket.meta.priority,
created_at: ticket.meta.created_at,
updated_at: ticket.meta.updated_at,
content_digest,
closure_operation_key: ticket.closure_operation_key.clone(),
current_closure: ticket.current_closure.clone(),
closure_history: ticket.case_history.clone(),
legacy_handoff: ticket.legacy_workflow_snapshot.clone(),
queued_by: ticket.meta.queued_by,
queued_at: ticket.meta.queued_at,
targets,
@@ -963,6 +945,44 @@ impl SqliteWorkspaceAuthority {
}
}
impl SqliteWorkspaceAuthority {
/// Bounded list projection: read only one immutable closure record and the
/// migration handoff, never the full body or event history for every row.
fn populate_ticket_summary_case_fields(&self, summary: &mut TicketSummary) -> Result<()> {
self.store.with_conn(|conn| {
summary.closure_operation_key = conn.query_row(
"SELECT closure_operation_key FROM typed_ticket_case_metadata WHERE workspace_id=?1 AND ticket_id=?2",
params![self.workspace_id, summary.id], |row| row.get(0)
).optional()?.flatten();
if summary.state == server_api::BrowserTicketState::Closed {
if let Some(key) = &summary.closure_operation_key {
let json: Option<String> = conn.query_row(
"SELECT judgment_json FROM typed_ticket_case_history WHERE workspace_id=?1 AND ticket_id=?2 AND operation_key=?3",
params![self.workspace_id, summary.id, key], |row| row.get(0)
).optional()?;
if let Some(json) = json {
let entry: ticket::TicketCaseHistoryEntry = serde_json::from_str(&json).map_err(|e| Error::Store(e.to_string()))?;
let reason = match entry.action {
ticket::TicketCaseAction::Close { reason } | ticket::TicketCaseAction::CorrectClosure { reason } => reason,
ticket::TicketCaseAction::Reopen => return Err(Error::Store("Closure key names a reopen receipt".into())),
};
summary.current_closure = Some(ticket::TicketClosureJudgment {
operation_key: entry.operation_key, reason, explanation: entry.explanation,
references: entry.references, author: entry.author, at: entry.at,
});
}
}
}
let snapshot: Option<String> = conn.query_row(
"SELECT snapshot_json FROM typed_ticket_legacy_workflow_snapshot WHERE workspace_id=?1 AND ticket_id=?2",
params![self.workspace_id, summary.id], |row| row.get(0)
).optional()?;
summary.legacy_handoff = snapshot.map(|json| serde_json::from_str(&json).map_err(|e| Error::Store(e.to_string()))).transpose()?;
Ok(())
})
}
}
impl TicketAuthority for SqliteWorkspaceAuthority {
fn list_tickets(&self, limit: usize) -> Result<ProjectRecordList<TicketSummary>> {
let projection = self.ticket_backend.list_workspace_projection(limit)?;
@@ -970,24 +990,9 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
.items
.into_iter()
.map(|item| {
let projection =
project_ticket_workspace_item(&item.summary, &item.relation_blockers, None);
let resource_key = item.summary.resource_key.clone().ok_or_else(|| {
Error::Store(format!("missing resource key for {}", item.summary.id))
})?;
Ok::<_, Error>(TicketSummary {
resource_key,
id: item.summary.id,
title: item.summary.title,
state: item.summary.workflow_state.as_str().to_string(),
priority: item.summary.priority,
updated_at: item.summary.updated_at,
queued_by: item.summary.queued_by,
queued_at: item.summary.queued_at,
workspace_action_priority: workspace_action_priority_name(projection.priority)
.to_string(),
record_source: "sqlite_yoi_ticket".to_string(),
})
let mut summary = ticket_summary_from_sqlite_item(item)?;
self.populate_ticket_summary_case_fields(&mut summary)?;
Ok(summary)
})
.collect::<Result<Vec<_>>>()?;
Ok(ProjectRecordList {
@@ -1005,7 +1010,7 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
let parsed_states = states
.iter()
.map(|state| {
TicketWorkflowState::parse(state).ok_or_else(|| {
TicketState::parse(state).ok_or_else(|| {
Error::InvalidRecordId(format!("unsupported ticket state `{state}`"))
})
})
@@ -1029,7 +1034,11 @@ impl TicketAuthority for SqliteWorkspaceAuthority {
let items = page
.items
.into_iter()
.map(ticket_summary_from_sqlite_item)
.map(|item| {
let mut summary = ticket_summary_from_sqlite_item(item)?;
self.populate_ticket_summary_case_fields(&mut summary)?;
Ok(summary)
})
.collect::<Result<Vec<_>>>()?;
let next_cursor = page
.next
@@ -1945,13 +1954,6 @@ fn ticket_evidence_summary(
}
fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
for state in &query.states {
if ticket::TicketWorkflowState::parse(state).is_none() {
return Err(Error::InvalidRecordId(format!(
"unsupported Ticket workflow state `{state}`"
)));
}
}
for evidence in &query.evidence {
if !matches!(
evidence.as_str(),
@@ -1962,25 +1964,6 @@ fn validate_ticket_query(query: &TicketQueryRequest) -> Result<()> {
)));
}
}
for attention in &query.attention {
if !matches!(
attention.as_str(),
"done_not_closed"
| "unresolved_review"
| "missing_commit"
| "blocked"
| "unblocked"
| "ready"
| "awaiting_review"
| "unresolved_changes"
| "stale_after_rescope"
| "missing_evidence"
) {
return Err(Error::InvalidRecordId(format!(
"unsupported Ticket attention filter `{attention}`"
)));
}
}
if let Some(status) = query.review_status.as_deref()
&& !matches!(
status,
@@ -2125,19 +2108,17 @@ fn ticket_evidence_matches(evidence: &TicketEvidenceSummary, filter: &str) -> bo
}
fn ticket_attention_matches(
state: &str,
_state: &str,
evidence: &TicketEvidenceSummary,
is_blocked: bool,
authoritative_events: &[TicketEvent],
attention: &str,
) -> bool {
match attention {
"done_not_closed" => state == "done",
"unresolved_review" => evidence.unresolved_request_changes,
"missing_commit" => !evidence.has_commit,
"blocked" => is_blocked,
"unblocked" => !is_blocked,
"ready" => state == "ready" && !is_blocked,
"awaiting_review" => {
evidence.has_review_request && evidence.review_status.as_deref() == Some("pending")
}
@@ -2209,11 +2190,11 @@ fn ticket_matches_query(
}
if !query.attention.iter().all(|attention| {
ticket_attention_matches(
&summary.state,
summary.state.as_str(),
&detail.evidence,
!detail.relations.blockers.is_empty(),
authoritative_events,
attention,
attention.as_str(),
)
}) {
return false;
@@ -2323,6 +2304,9 @@ fn ticket_query_item(
created_at: detail.created_at.clone(),
updated_at: summary.updated_at,
content_digest: detail.content_digest.clone(),
closure_operation_key: detail.closure_operation_key.clone(),
current_closure: detail.current_closure.clone(),
legacy_handoff: detail.legacy_handoff.clone(),
workspace_action_priority: summary.workspace_action_priority,
matched_fields,
snippet: snippet.map(|value| truncate_body(&value, 512).0),
@@ -2682,10 +2666,14 @@ fn ticket_summary_from_ticket(ticket: &ticket::Ticket) -> Result<TicketSummary>
queued_at: ticket.meta.queued_at.clone(),
updated_at: ticket.meta.updated_at.clone(),
};
ticket_summary_from_sqlite_item(SqliteTicketListItem {
let mut summary = ticket_summary_from_sqlite_item(SqliteTicketListItem {
summary,
relation_blockers: ticket.relations.blockers.clone(),
})
})?;
summary.closure_operation_key = ticket.closure_operation_key.clone();
summary.current_closure = ticket.current_closure.clone();
summary.legacy_handoff = ticket.legacy_workflow_snapshot.clone();
Ok(summary)
}
fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketSummary> {
@@ -2699,13 +2687,16 @@ fn ticket_summary_from_sqlite_item(item: SqliteTicketListItem) -> Result<TicketS
resource_key,
id: item.summary.id,
title: item.summary.title,
state: item.summary.workflow_state.as_str().to_string(),
state: item.summary.workflow_state.into(),
priority: item.summary.priority,
updated_at: item.summary.updated_at,
queued_by: item.summary.queued_by,
queued_at: item.summary.queued_at,
workspace_action_priority: workspace_action_priority_name(projection.priority).to_string(),
record_source: "sqlite_yoi_ticket".to_string(),
closure_operation_key: None,
current_closure: None,
legacy_handoff: None,
})
}
@@ -3222,14 +3213,7 @@ mod tests {
.create(ticket::NewTicket::new("Explicit work"))
.unwrap();
let authority = SqliteWorkspaceAuthority::new(&path, "w").unwrap();
for (state, allowed) in [
("planning", true),
("ready", true),
("queued", true),
("inprogress", true),
("done", false),
("closed", false),
] {
for (state, allowed) in [("open", true), ("closed", false)] {
conn.execute(
"UPDATE typed_tickets SET workflow_state=?1 WHERE ticket_id=?2",
rusqlite::params![state, ticket.id],
@@ -3246,7 +3230,7 @@ mod tests {
);
}
conn.execute(
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1",
"UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id=?1",
[&ticket.id],
)
.unwrap();
@@ -3302,7 +3286,7 @@ mod tests {
)
.unwrap();
conn.execute(
"UPDATE typed_tickets SET workflow_state='planning' WHERE ticket_id=?1",
"UPDATE typed_tickets SET workflow_state='open' WHERE ticket_id=?1",
[&ticket.id],
)
.unwrap();
@@ -3353,11 +3337,11 @@ INSERT INTO typed_tickets (
) VALUES
('workspace-test', '00000000001J2', '00000000001J2', 'Read bridge', 'open', 'ticket', 'P2',
'Ticket body. Deep Ticket marker. This body is intentionally long enough for bounded projection tests.',
'2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'ready', 1),
'2026-01-01T00:00:00Z', '2026-01-03T00:00:00Z', 'open', 1),
('workspace-test', '00000000001J5', '00000000001J5', 'Second ticket', 'open', 'ticket', 'P2',
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'queued', 1),
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-02T00:00:00Z', 'open', 1),
('workspace-test', '00000000001J6', '00000000001J6', 'Third ticket', 'open', 'ticket', 'P2',
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'planning', 1);
'Ticket body', '2026-01-01T00:00:00Z', '2026-01-01T00:00:00Z', 'open', 1);
INSERT INTO typed_ticket_events (
workspace_id, ticket_id, event_index, kind, author, at, heading, body
@@ -3496,11 +3480,8 @@ VALUES ('workspace-test', 'ticket', 4);
assert_eq!(tickets.record_authority, "workspace-sqlite");
assert_eq!(tickets.items[0].record_source, "sqlite_yoi_ticket");
assert_eq!(tickets.items[0].id, "00000000001J2");
assert_eq!(tickets.items[0].state, "ready");
assert_eq!(
tickets.items[0].workspace_action_priority,
"ready_for_queue"
);
assert_eq!(tickets.items[0].state.as_str(), "open");
assert_eq!(tickets.items[0].workspace_action_priority, "background");
let ticket_by_key = authority.ticket(&tickets.items[0].resource_key).unwrap();
assert_eq!(ticket_by_key.id, tickets.items[0].id);
@@ -3516,9 +3497,9 @@ VALUES ('workspace-test', 'ticket', 4);
let ticket_query = authority
.query_tickets(TicketQueryRequest {
query: Some("Deep Ticket marker".to_string()),
states: vec!["ready".to_string()],
states: vec![server_api::BrowserTicketState::Open],
linked_objective_id: Some("00000000001J3".to_string()),
attention: vec!["missing_commit".to_string()],
attention: vec![server_api::TicketAttention::MissingCommit],
limit: Some(1),
..TicketQueryRequest::default()
})
@@ -3617,7 +3598,7 @@ VALUES ('workspace-test', 'ticket', 4);
assert_eq!(incoming_relation.items[0].id, "00000000001J5");
let summary_page = authority
.list_ticket_page(TicketListProjectionRequest {
states: vec!["planning".to_string(), "ready".to_string()],
states: vec!["open".to_string()],
limit: Some(1),
cursor: None,
})
@@ -3625,7 +3606,7 @@ VALUES ('workspace-test', 'ticket', 4);
assert_eq!(summary_page.items.len(), 1);
assert!(summary_page.page.has_more);
let mismatched_summary_cursor = authority.list_ticket_page(TicketListProjectionRequest {
states: vec!["done".to_string()],
states: vec!["closed".to_string()],
limit: Some(1),
cursor: summary_page.page.next_cursor,
});
@@ -3694,7 +3675,7 @@ VALUES ('workspace-test', 'ticket', 4);
let objective = authority.objective("00000000001J3").unwrap();
assert!(objective.body.contains("Objective body"));
assert_eq!(objective.linked_ticket_summaries[0].id, "00000000001J2");
assert_eq!(objective.linked_ticket_summaries[0].state, "ready");
assert_eq!(objective.linked_ticket_summaries[0].state, "open");
let linked_ticket_key = objective.linked_ticket_summaries[0].resource_key.clone();
let objective_query = authority
.query_objectives(ObjectiveQueryRequest {
@@ -3783,8 +3764,8 @@ INSERT INTO typed_tickets (
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit
) VALUES
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'planning', 1),
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'planning', 1);
('workspace-test', '00000000001J2', 'ticket-j2', 'Ticket J2', 'open', 'task', 'normal', '', 'open', 1),
('workspace-test', '00000000001J3', 'ticket-j3', 'Ticket J3', 'open', 'task', 'normal', '', 'open', 1);
INSERT INTO workspace_resource_keys (
workspace_id, resource_kind, resource_id, sequence, resource_key, allocated_at
) VALUES
@@ -1689,3 +1689,114 @@ CREATE TABLE repository_secret_legacy_receipts (
FOREIGN KEY (workspace_id, operation_id)
REFERENCES repository_secret_operations(workspace_id, operation_id) ON DELETE CASCADE
);
-- T715: responsibility is retained; closing irreversibly releases an assignment identity.
CREATE TABLE IF NOT EXISTS ticket_assignment_work_releases (
workspace_id TEXT NOT NULL,
assignment_id TEXT NOT NULL,
released_at TEXT NOT NULL,
PRIMARY KEY (workspace_id, assignment_id),
FOREIGN KEY (workspace_id, assignment_id)
REFERENCES ticket_worker_assignments(workspace_id, assignment_id) ON DELETE CASCADE
);
-- Ticket case assignment work constraints (schema 90).
DROP VIEW IF EXISTS ticket_active_worker_assignments;
CREATE VIEW ticket_active_worker_assignments AS
SELECT current.* FROM ticket_current_worker_assignments AS current
JOIN typed_tickets AS ticket
ON ticket.workspace_id = current.workspace_id AND ticket.ticket_id = current.ticket_id
WHERE ticket.workflow_state = 'open'
AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases AS released
WHERE released.workspace_id = current.workspace_id
AND released.assignment_id = current.assignment_id);
DROP TRIGGER IF EXISTS ticket_terminal_releases_work;
CREATE TRIGGER ticket_terminal_releases_work
AFTER UPDATE OF workflow_state ON typed_tickets
WHEN NEW.workflow_state = 'closed'
BEGIN
INSERT OR IGNORE INTO ticket_assignment_work_releases
SELECT workspace_id, assignment_id, COALESCE(NEW.updated_at, updated_at)
FROM ticket_current_worker_assignments
WHERE workspace_id = NEW.workspace_id AND ticket_id = NEW.ticket_id;
END;
DROP TRIGGER IF EXISTS ticket_terminal_assignment_releases_work_insert;
CREATE TRIGGER ticket_terminal_assignment_releases_work_insert
AFTER INSERT ON ticket_current_worker_assignments
WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
AND ticket_id = NEW.ticket_id AND workflow_state = 'closed')
BEGIN
INSERT OR IGNORE INTO ticket_assignment_work_releases
VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at);
END;
DROP TRIGGER IF EXISTS ticket_terminal_assignment_releases_work_update;
CREATE TRIGGER ticket_terminal_assignment_releases_work_update
AFTER UPDATE ON ticket_current_worker_assignments
WHEN EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
AND ticket_id = NEW.ticket_id AND workflow_state = 'closed')
BEGIN
INSERT OR IGNORE INTO ticket_assignment_work_releases
VALUES (NEW.workspace_id, NEW.assignment_id, NEW.updated_at);
END;
DROP TRIGGER IF EXISTS ticket_active_worker_role_insert;
CREATE TRIGGER ticket_active_worker_role_insert
BEFORE INSERT ON ticket_current_worker_assignments
WHEN NEW.principal_kind = 'worker'
AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
AND ticket_id = NEW.ticket_id AND workflow_state = 'open')
AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases
WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id)
AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id = NEW.workspace_id AND role = NEW.role
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
AND assignment_id != NEW.assignment_id)
BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END;
DROP TRIGGER IF EXISTS ticket_active_worker_role_update;
CREATE TRIGGER ticket_active_worker_role_update
BEFORE UPDATE ON ticket_current_worker_assignments
WHEN NEW.principal_kind = 'worker'
AND EXISTS (SELECT 1 FROM typed_tickets WHERE workspace_id = NEW.workspace_id
AND ticket_id = NEW.ticket_id AND workflow_state = 'open')
AND NOT EXISTS (SELECT 1 FROM ticket_assignment_work_releases
WHERE workspace_id = NEW.workspace_id AND assignment_id = NEW.assignment_id)
AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id = NEW.workspace_id AND role = NEW.role
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
AND assignment_id != OLD.assignment_id)
BEGIN SELECT RAISE(ABORT, 'Worker already has unfinished Ticket work in this role'); END;
-- Keep archived responsibilities valid after Worker removal, but never allow
-- newly installed identities to refer to a removed / removal-reserved Worker.
DROP TRIGGER IF EXISTS ticket_active_worker_principal_insert;
CREATE TRIGGER ticket_active_worker_principal_insert
BEFORE INSERT ON ticket_current_worker_assignments
WHEN NEW.principal_kind = 'worker' AND (
NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id)
OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
AND state IN ('executing', 'failed', 'succeeded')))
BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END;
DROP TRIGGER IF EXISTS ticket_active_worker_principal_update;
CREATE TRIGGER ticket_active_worker_principal_update
BEFORE UPDATE ON ticket_current_worker_assignments
WHEN NEW.principal_kind = 'worker' AND (
NOT EXISTS (SELECT 1 FROM worker_registry WHERE workspace_id = NEW.workspace_id
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id)
OR EXISTS (SELECT 1 FROM worker_removal_operations WHERE workspace_id = NEW.workspace_id
AND runtime_id = NEW.runtime_id AND worker_id = NEW.worker_id
AND state IN ('executing', 'failed', 'succeeded')))
BEGIN SELECT RAISE(ABORT, 'Worker is missing or removal is reserved'); END;
DROP TRIGGER IF EXISTS ticket_unfinished_work_blocks_worker_delete;
CREATE TRIGGER ticket_unfinished_work_blocks_worker_delete
BEFORE DELETE ON worker_registry
WHEN EXISTS (SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id
AND worker_id = OLD.worker_id)
BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END;
DROP TRIGGER IF EXISTS ticket_unfinished_work_blocks_worker_move;
CREATE TRIGGER ticket_unfinished_work_blocks_worker_move
BEFORE UPDATE OF runtime_id, worker_id, workspace_id ON worker_registry
WHEN (OLD.runtime_id != NEW.runtime_id OR OLD.worker_id != NEW.worker_id OR OLD.workspace_id != NEW.workspace_id)
AND EXISTS (SELECT 1 FROM ticket_active_worker_assignments
WHERE workspace_id = OLD.workspace_id AND runtime_id = OLD.runtime_id
AND worker_id = OLD.worker_id)
BEGIN SELECT RAISE(ABORT, 'Worker has unfinished Ticket work'); END;
@@ -48,7 +48,7 @@ fn evidence_column_cutover_preserves_all_values_and_current_schema_names() {
let upgraded = SqliteWorkspaceStore::open(&path).unwrap();
upgraded
.with_conn(|conn| {
assert_eq!(current_schema_version(conn)?, 89);
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
assert_eq!(evidence_rows(conn, "artifacts"), artifacts);
assert_eq!(
evidence_rows(conn, "repository_secret_legacy_receipts"),
+133 -131
View File
@@ -1136,7 +1136,7 @@ mod tests {
)?;
c.execute(
"INSERT INTO typed_tickets (workspace_id, ticket_id, slug, title, status, kind, priority, body, workflow_state, workflow_state_explicit) \
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'planning', 1)",
VALUES ('w', 'ticket', 'ticket', 'Ticket', 'open', 'task', 'normal', '', 'open', 1)",
[],
)?;
Ok(())
@@ -1398,7 +1398,7 @@ mod tests {
})
.unwrap();
s.with_conn(|conn| {
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','planning',1)", [])?;
conn.execute("INSERT INTO typed_tickets(workspace_id,ticket_id,slug,title,status,kind,priority,body,workflow_state,workflow_state_explicit) VALUES('w','ticket-old','ticket-old','Old Ticket','open','task','normal','','open',1)", [])?;
conn.execute("INSERT INTO worker_registry(workspace_id,worker_id,runtime_id,display_name,profile,retention_state,created_at,updated_at) VALUES('w','1','r','old worker','builtin:coder','normal','created','2026-08-24T00:00:00Z')", [])?;
conn.execute("INSERT INTO worker_create_reservations(workspace_id,allocation_key,worker_id,runtime_id,create_fingerprint,state,created_at,updated_at) VALUES('w','allocation-old',?1,'r','fingerprint','created','created','created')", [worker_id().to_string()])?;
conn.execute("INSERT INTO ticket_worker_assignments(workspace_id,ticket_id,assignment_id,runtime_id,worker_id,assigned_by,assigned_at) VALUES('w','ticket-old','assignment-old','r','1','test','t')", [])?;
@@ -1522,24 +1522,38 @@ mod tests {
install_current_assignment(s, "assignment").unwrap();
}
fn reopened_terminal_assignment_store(terminal_state: &str) -> SqliteWorkspaceStore {
fn reopened_terminal_assignment_store() -> SqliteWorkspaceStore {
let store = setup();
seed_current_assignment(&store);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET workflow_state=?1
"UPDATE typed_tickets SET status='closed', workflow_state='closed'
WHERE workspace_id='w' AND ticket_id='ticket'",
[terminal_state],
[],
)?;
conn.execute(
"UPDATE typed_tickets SET workflow_state='planning'
"UPDATE typed_tickets SET status='open', workflow_state='open'
WHERE workspace_id='w' AND ticket_id='ticket'",
[],
)?;
Ok(())
})
.unwrap();
// Narrow lifecycle-schema evidence: closing durably releases work,
// while reopening retains routing without reviving that assignment.
let released: i64 = store
.with_conn(|conn| {
conn.query_row(
"SELECT COUNT(*) FROM ticket_assignment_work_releases
WHERE workspace_id='w' AND assignment_id='assignment'",
[],
|row| row.get(0),
)
.map_err(StoreError::from)
})
.unwrap();
assert_eq!(released, 1);
store
}
@@ -1646,103 +1660,93 @@ mod tests {
#[test]
fn terminal_retained_assignment_allows_worker_removal_even_after_reopen() {
for (status, workflow_state) in [("closed", "closed"), ("open", "done")] {
for reopen in [false, true] {
let store = setup();
seed_current_assignment(&store);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET status=?1, workflow_state=?2
for reopen in [false, true] {
let store = setup();
seed_current_assignment(&store);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET status='closed', workflow_state='closed'
WHERE workspace_id='w' AND ticket_id='ticket'",
params![status, workflow_state],
)?;
if reopen {
conn.execute(
"UPDATE typed_tickets SET status='open', workflow_state='planning'
[],
)?;
if reopen {
conn.execute(
"UPDATE typed_tickets SET status='open', workflow_state='open'
WHERE workspace_id='w' AND ticket_id='ticket'",
[],
)?;
}
Ok(())
})
.unwrap();
store
.update_worker_retention_policy(
"w",
&store
.worker_retention_policy("w")
.unwrap()
.unwrap()
.policy_digest,
&WorkerRetentionPolicyUpdate {
policy_id: "purge".into(),
session_disposition: SessionDisposition::Purge,
metadata_disposition: MetadataDisposition::Tombstone,
archive_retention: ArchiveRetention::Forever,
diagnostics_disposition: DiagnosticsDisposition::Purge,
diagnostics_retention_seconds: None,
},
)
.unwrap();
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
assert!(
plan.blockers.is_empty(),
"{status}/{workflow_state}, reopened={reopen}"
);
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap();
let result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
worker_id: worker_id(),
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
archive: None,
source_removed: true,
diagnostics_retained: false,
};
let committed = store
.commit_worker_removal(
"w",
&plan.operation_id,
&plan.input_fingerprint,
&result,
)
.unwrap();
assert_eq!(committed.plan.state, WorkerRemovalPlanState::Succeeded);
assert!(
store
.get_worker_registry("w", &plan.worker)
[],
)?;
}
Ok(())
})
.unwrap();
store
.update_worker_retention_policy(
"w",
&store
.worker_retention_policy("w")
.unwrap()
.is_none()
);
assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_some());
let (retained, historical): (i64, i64) = store
.with_conn(|conn| {
Ok((
conn.query_row(
"SELECT COUNT(*) FROM ticket_current_worker_assignments
.unwrap()
.policy_digest,
&WorkerRetentionPolicyUpdate {
policy_id: "purge".into(),
session_disposition: SessionDisposition::Purge,
metadata_disposition: MetadataDisposition::Tombstone,
archive_retention: ArchiveRetention::Forever,
diagnostics_disposition: DiagnosticsDisposition::Purge,
diagnostics_retention_seconds: None,
},
)
.unwrap();
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
assert!(plan.blockers.is_empty(), "closed case, reopened={reopen}");
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap();
let result = WorkerRetentionExecutionResult {
operation_id: plan.operation_id.clone(),
input_fingerprint: plan.input_fingerprint.clone(),
worker_id: worker_id(),
session_disposition: plan.session_disposition,
diagnostics_disposition: plan.diagnostics_disposition,
archive: None,
source_removed: true,
diagnostics_retained: false,
};
let committed = store
.commit_worker_removal("w", &plan.operation_id, &plan.input_fingerprint, &result)
.unwrap();
assert_eq!(committed.plan.state, WorkerRemovalPlanState::Succeeded);
assert!(
store
.get_worker_registry("w", &plan.worker)
.unwrap()
.is_none()
);
assert!(store.worker_tombstone("w", &plan.worker).unwrap().is_some());
let (retained, historical): (i64, i64) = store
.with_conn(|conn| {
Ok((
conn.query_row(
"SELECT COUNT(*) FROM ticket_current_worker_assignments
WHERE workspace_id='w' AND assignment_id='assignment'",
[],
|row| row.get(0),
)?,
conn.query_row(
"SELECT COUNT(*) FROM ticket_worker_assignments
[],
|row| row.get(0),
)?,
conn.query_row(
"SELECT COUNT(*) FROM ticket_worker_assignments
WHERE workspace_id='w' AND assignment_id='assignment'",
[],
|row| row.get(0),
)?,
))
})
.unwrap();
assert_eq!(
(retained, historical),
(1, 1),
"assignment history must survive removal"
);
}
[],
|row| row.get(0),
)?,
))
})
.unwrap();
assert_eq!(
(retained, historical),
(1, 1),
"assignment history must survive removal"
);
}
}
@@ -1768,42 +1772,40 @@ mod tests {
#[test]
fn distinct_assignment_after_terminal_end_blocks_preparation() {
for terminal_state in ["done", "closed"] {
let store = reopened_terminal_assignment_store(terminal_state);
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
assert!(plan.blockers.is_empty());
install_current_assignment(&store, "new-assignment").unwrap();
let blocked = store.plan_worker_removal(&req(), &inv()).unwrap();
assert_eq!(
blocked.blockers,
vec![WorkerRemovalBlocker::UnfinishedWork {
assignment_id: "new-assignment".into(),
ticket_id: "ticket".into(),
}]
);
let replay = store.plan_worker_removal(&req(), &inv()).unwrap();
assert_eq!(replay.plan_id, blocked.plan_id);
assert_eq!(replay.input_fingerprint, blocked.input_fingerprint);
assert_ne!(blocked.plan_id, plan.plan_id);
let error = store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap_err();
assert!(
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
let store = reopened_terminal_assignment_store();
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
assert!(plan.blockers.is_empty());
install_current_assignment(&store, "new-assignment").unwrap();
let blocked = store.plan_worker_removal(&req(), &inv()).unwrap();
assert_eq!(
blocked.blockers,
vec![WorkerRemovalBlocker::UnfinishedWork {
assignment_id: "new-assignment".into(),
ticket_id: "ticket".into(),
}]
);
let replay = store.plan_worker_removal(&req(), &inv()).unwrap();
assert_eq!(replay.plan_id, blocked.plan_id);
assert_eq!(replay.input_fingerprint, blocked.input_fingerprint);
assert_ne!(blocked.plan_id, plan.plan_id);
let error = store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
.unwrap_err();
assert!(
matches!(error, WorkerRetentionError::StalePlan { reason, .. }
if reason == "unfinished work added")
);
assert!(
store
.get_worker_registry("w", &plan.worker)
.unwrap()
.is_some()
);
}
);
assert!(
store
.get_worker_registry("w", &plan.worker)
.unwrap()
.is_some()
);
}
#[test]
fn prepared_removal_fences_distinct_assignment_after_terminal_end() {
let store = reopened_terminal_assignment_store("done");
let store = reopened_terminal_assignment_store();
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
@@ -1837,7 +1839,7 @@ mod tests {
#[test]
fn commit_rechecks_distinct_assignment_independently_of_admission_fence() {
let store = reopened_terminal_assignment_store("done");
let store = reopened_terminal_assignment_store();
let plan = store.plan_worker_removal(&req(), &inv()).unwrap();
store
.prepare_worker_removal_execution("w", &plan.plan_id, &plan.input_fingerprint)
@@ -1938,7 +1940,7 @@ mod tests {
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET workflow_state='done'
"UPDATE typed_tickets SET status='closed', workflow_state='closed'
WHERE workspace_id='w' AND ticket_id='ticket'",
[],
)?;
File diff suppressed because it is too large Load Diff
@@ -131,8 +131,7 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() {
})
.unwrap();
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Cancellation logging");
input.workflow_state = Some(TicketWorkflowState::InProgress);
let input = ticket::NewTicket::new("Cancellation logging");
let ticket = backend.create(input).unwrap();
api.store
.set_current_ticket_worker_assignment(
@@ -197,8 +196,8 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() {
"{diagnostics}"
);
assert_eq!(
api.authority.ticket(&ticket.id).unwrap().state,
"inprogress"
api.authority.ticket(&ticket.id).unwrap().state.as_str(),
"open"
);
// Errors without diagnostics still carry a useful kind and message.
@@ -213,7 +212,32 @@ async fn generated_cancellation_errors_preserve_response_and_log_details() {
*execution.cancel_failure.lock().unwrap() = None;
let (status, body, events) = request_with_logs(app, request("body-secret")).await;
assert_eq!(status, StatusCode::OK, "{body}");
assert_eq!(body["state"], "ready");
assert_eq!(body["state"], "open");
assert!(
api.store
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id)
.unwrap()
.is_none()
);
assert!(
api.store
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket.id)
.unwrap()
.is_none()
);
let assignment_history = api
.store
.list_ticket_worker_assignment_events(TEST_WORKSPACE_ID, &ticket.id, 10)
.unwrap();
assert!(
assignment_history
.iter()
.any(|event| event.action == "unassigned"
&& event.previous_assignment_id.as_deref() == Some("logging-assignment"))
);
let after = backend.show(ticket.id.into()).unwrap();
assert!(after.case_history.is_empty());
assert!(after.current_closure.is_none());
assert!(
events.is_empty(),
"successful cancellation logged as failure: {events:?}"
@@ -47,7 +47,6 @@ impl EvidenceApiFixture {
assert!(!temp.path().join(".git").exists());
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("T716 remote immutable evidence");
input.workflow_state = Some(TicketWorkflowState::InProgress);
input.targets = vec![ticket::TicketTarget {
repository_key: "test-repository".into(),
ref_selector: Some("develop".into()),
@@ -191,12 +190,16 @@ impl EvidenceApiFixture {
.unwrap()
}
fn completion(&self, _approval: &ReviewEvent) -> ticket::TicketCompletion {
ticket::TicketCompletion {
operation_key: "evidence-ticket-completion".into(),
fn closure(&self) -> ticket::TicketCaseUpdate {
ticket::TicketCaseUpdate {
operation_key: "evidence-ticket-closure".into(),
expected_content_digest: self.content_digest(),
expected_state: TicketWorkflowState::InProgress,
reason: "Implementation judged complete independently of MR attestation".into(),
expected_state: ticket::TicketState::Open,
expected_closure_operation_key: None,
action: ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Completed,
},
explanation: "Implementation judged complete independently of MR attestation".into(),
references: Vec::new(),
author: Some("fixture".into()),
}
@@ -297,14 +300,14 @@ impl EvidenceApiFixture {
}
#[tokio::test]
async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() {
async fn closed_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_paged_detail() {
let fixture = EvidenceApiFixture::new().await;
let approval = fixture.approve("integration");
let merge = fixture.integrate(&approval);
let content_digest = fixture.content_digest();
let completion = fixture
.backend
.complete(&fixture.ticket_id, fixture.completion(&approval))
.update_case(&fixture.ticket_id, fixture.closure())
.unwrap();
assert_eq!(ticket::ticket_content_digest(&completion), content_digest);
assert!(
@@ -325,7 +328,7 @@ async fn done_merged_remote_ticket_uses_immutable_result_in_show_query_list_and_
);
let shown = fixture.show().await;
assert_eq!(shown.state, "done");
assert_eq!(shown.state.as_str(), "closed");
assert_eq!(shown.content_digest, content_digest);
assert!(
shown.evidence.complete_for_integration,
@@ -395,15 +398,15 @@ async fn latest_postmerge_approval_requires_exact_content_and_stored_source_resu
.contains(&"review_after_rescope".into())
);
fixture.assert_filters(false, true, true).await;
let mut outdated_completion = fixture.completion(&integration);
outdated_completion.expected_content_digest = integration.ticket_content_digest.clone();
let mut outdated_closure = fixture.closure();
outdated_closure.expected_content_digest = integration.ticket_content_digest.clone();
assert!(
fixture
.backend
.complete(&fixture.ticket_id, outdated_completion)
.update_case(&fixture.ticket_id, outdated_closure)
.is_err()
);
assert_eq!(fixture.show().await.state, "inprogress");
assert_eq!(fixture.show().await.state.as_str(), "open");
let latest = fixture.approve("latest-content");
assert_eq!(latest.created_at, integration.created_at);
@@ -467,7 +470,7 @@ async fn latest_postmerge_approval_requires_exact_content_and_stored_source_resu
assert!(wrong.evidence.approved_current_subject);
assert!(!wrong.evidence.complete_for_integration);
fixture.assert_filters(false, true, true).await;
assert_eq!(fixture.show().await.state, "inprogress");
assert_eq!(fixture.show().await.state.as_str(), "open");
let repaired = fixture.approve("exact-snapshot");
fixture.assert_filters(true, false, false).await;
@@ -488,10 +491,10 @@ async fn latest_postmerge_approval_requires_exact_content_and_stored_source_resu
assert_eq!(detail.target.resolved_ref.as_deref(), Some(EVIDENCE_TARGET));
let completion = fixture
.backend
.complete(&fixture.ticket_id, fixture.completion(&repaired))
.update_case(&fixture.ticket_id, fixture.closure())
.unwrap();
assert_eq!(ticket::ticket_content_digest(&completion), content_digest);
assert_eq!(fixture.show().await.state, "done");
assert_eq!(fixture.show().await.state.as_str(), "closed");
fixture.assert_filters(true, false, false).await;
}
@@ -645,14 +648,18 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta
.unwrap();
fixture
.backend
.update_state(
.update_case(
&fixture.ticket_id,
ticket::TicketStateUpdate {
ticket::TicketCaseUpdate {
operation_key: "close-with-open-mr".into(),
expected_content_digest: fixture.content_digest(),
expected_state: TicketWorkflowState::InProgress,
state: TicketWorkflowState::Closed,
reason: "The request was withdrawn; no integration or approval is asserted".into(),
expected_state: ticket::TicketState::Open,
expected_closure_operation_key: None,
action: ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::NotPlanned,
},
explanation: "The request was withdrawn; no integration or approval is asserted"
.into(),
references: Vec::new(),
author: Some("workspace-user".into()),
},
@@ -667,13 +674,13 @@ async fn closing_ticket_with_open_unreviewed_mr_does_not_change_its_proof_or_sta
serde_json::to_value(before).unwrap()
);
let shown = fixture.show().await;
assert_eq!(shown.state, "closed");
assert_eq!(shown.state.as_str(), "closed");
assert!(!shown.evidence.approved_current_subject);
assert_eq!(shown.merge_requests[0].state, "open");
}
#[tokio::test]
async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_evidence() {
async fn mrless_completed_closure_is_a_ticket_judgment_not_approved_or_missing_merge_evidence() {
let temp = tempfile::tempdir().unwrap();
let api = test_api_with_remote_repository(temp.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
@@ -682,13 +689,17 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev
.unwrap();
let ticket = backend.show(reference.id.clone().into()).unwrap();
backend
.complete(
.update_case(
&reference.id,
ticket::TicketCompletion {
ticket::TicketCaseUpdate {
operation_key: "research-result".into(),
expected_content_digest: ticket::ticket_content_digest(&ticket),
expected_state: TicketWorkflowState::Planning,
reason: "Answer recorded in the Ticket thread".into(),
expected_state: ticket::TicketState::Open,
expected_closure_operation_key: None,
action: ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Completed,
},
explanation: "Answer recorded in the Ticket thread".into(),
references: Vec::new(),
author: Some("workspace-user".into()),
},
@@ -703,7 +714,7 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev
.await,
)
.unwrap();
assert_eq!(shown.state, "done");
assert_eq!(shown.state.as_str(), "closed");
assert!(!shown.evidence.has_merge_request);
assert!(!shown.evidence.approved_current_subject);
assert!(!shown.evidence.complete_for_integration);
@@ -728,8 +739,22 @@ async fn mrless_completion_is_a_ticket_judgment_not_approved_or_missing_merge_ev
}
}
// Exercise the generated public API through the router, then reread the real
// Ticket authority. No live Runtime, repository provider, or Worker process runs.
fn case_request(before: &ticket::Ticket, operation_key: &str, action: Value) -> Value {
json!({
"operation_key": operation_key,
"expected_content_digest": ticket::ticket_content_digest(before),
"expected_state": before.meta.workflow_state,
"expected_closure_operation_key": before.closure_operation_key,
"action": action,
"explanation": "Explicit user case judgment",
"references": [{"kind": "ticket", "target": "T-42"}],
})
}
#[tokio::test]
async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_receipts() {
async fn case_route_rejects_missing_cas_client_author_and_removed_workflow_routes() {
let temp = tempfile::tempdir().unwrap();
let api = test_api_with_remote_repository(temp.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
@@ -738,84 +763,319 @@ async fn all_public_state_and_close_routes_require_explicit_cas_and_operation_re
.unwrap();
let before = backend.show(reference.id.clone().into()).unwrap();
let app = build_inner_router(api);
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id);
for suffix in ["workflow-state", "state-changes", "state-fields/state"] {
let path = format!(
"/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case",
reference.id
);
let request = case_request(
&before,
"close",
json!({"action": "close", "reason": "completed"}),
);
for field in [
"operation_key",
"expected_content_digest",
"expected_state",
"action",
"explanation",
] {
let mut missing = request.clone();
missing.as_object_mut().unwrap().remove(field);
request_json(
app.clone(),
"POST",
&format!("{path}/{suffix}"),
Some(json!({
"from":"planning","to":"done","reason":"old unguarded request","body":""
})),
&path,
Some(missing),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await;
}
for (field, value) in [
("author", json!("spoofed-client-author")),
("expected_state", json!("done")),
("action", json!({"action": "close", "reason": "invented"})),
] {
let mut invalid = request.clone();
invalid[field] = value;
request_json(
app.clone(),
"POST",
&path,
Some(invalid),
StatusCode::UNPROCESSABLE_ENTITY,
)
.await;
}
let legacy = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id);
for suffix in [
"workflow-state",
"state-changes",
"state-fields/state",
"workflow/close",
"complete",
] {
// A surviving GET catch-all can produce 405 rather than 404 for an
// absent POST route. Neither status authorizes a legacy mutation.
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("{legacy}/{suffix}"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(request.to_string()))
.unwrap(),
)
.await
.unwrap();
assert!(
matches!(
response.status(),
StatusCode::NOT_FOUND | StatusCode::METHOD_NOT_ALLOWED
),
"legacy POST {suffix}: {}",
response.status()
);
}
request_json(
app,
"POST",
&format!(
"/workspaces/019d0000-0000-7000-8000-0000000000bb/tickets/{}/case",
reference.id
),
Some(request),
StatusCode::NOT_FOUND,
)
.await;
assert_eq!(backend.show(reference.id.into()).unwrap(), before);
}
#[tokio::test]
async fn case_route_preserves_corrected_history_replay_and_reopen_aba_guard_without_mr() {
let temp = tempfile::tempdir().unwrap();
let api = test_api_with_remote_repository(temp.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new(
"Case judgments without repository work",
))
.unwrap();
let before = backend.show(reference.id.clone().into()).unwrap();
let digest = ticket::ticket_content_digest(&before);
let app = build_inner_router(api);
let path = format!(
"/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case",
reference.id
);
let detail_path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{}", reference.id);
let close = case_request(
&before,
"guarded-close",
json!({"action": "close", "reason": "completed"}),
);
let response = request_json(
app.clone(),
"POST",
&path,
Some(close.clone()),
StatusCode::OK,
)
.await;
let summary: server_api::TicketSummary = serde_json::from_value(response).unwrap();
assert_eq!(summary.0.workflow_state, ticket::TicketState::Closed);
let closed = backend.show(reference.id.clone().into()).unwrap();
assert_eq!(
closed.current_closure.as_ref().unwrap().author,
"workspace-user"
);
assert_eq!(
closed.current_closure.as_ref().unwrap().reason,
ticket::TicketClosureReason::Completed
);
assert_eq!(closed.case_history.len(), 1);
assert_eq!(
closed.case_history[0].references,
closed.current_closure.as_ref().unwrap().references
);
let correction = case_request(
&closed,
"correct-closure",
json!({"action": "correct_closure", "reason": {"other": "Answer superseded by user decision"}}),
);
request_json(
app.clone(),
"POST",
&format!("{path}/workflow/close"),
Some(json!("unguarded close")),
StatusCode::UNPROCESSABLE_ENTITY,
&path,
Some(correction.clone()),
StatusCode::OK,
)
.await;
let close = json!({
"operation_key":"guarded-close","expected_content_digest":ticket::ticket_content_digest(&before),
"expected_state":"planning","reason":"No further work required","author":"spoofed-client-author"
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("{path}/workflow/close"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(close.to_string()))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let closed = backend.show(reference.id.clone().into()).unwrap();
assert_eq!(closed.meta.workflow_state, TicketWorkflowState::Closed);
let receipt = closed.events.last().unwrap();
assert_eq!(receipt.author.as_deref(), Some("workspace-user"));
assert_eq!(receipt.from.as_deref(), Some("planning"));
let corrected = backend.show(reference.id.clone().into()).unwrap();
assert_eq!(corrected.case_history.len(), 2);
assert_eq!(corrected.case_history[0], closed.case_history[0]);
assert_eq!(
receipt.attributes.get("operation_key"),
Some(&"guarded-close".to_string())
corrected.current_closure.as_ref().unwrap().reason,
ticket::TicketClosureReason::Other("Answer superseded by user decision".into())
);
let reopen = json!({
"operation_key":"reopen","expected_content_digest":ticket::ticket_content_digest(&closed),
"expected_state":"closed","state":"planning","reason":"A new question arrived"
});
let response = app
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(format!("{path}/workflow-state"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(reopen.to_string()))
.unwrap(),
)
.await
assert_eq!(
corrected.closure_operation_key.as_deref(),
Some("correct-closure")
);
let detail: TicketDetail =
serde_json::from_value(get_json(app.clone(), &detail_path).await).unwrap();
assert_eq!(detail.current_closure, corrected.current_closure);
assert_eq!(detail.closure_history, corrected.case_history);
assert_eq!(
detail.closure_operation_key,
corrected.closure_operation_key
);
assert!(detail.legacy_handoff.is_none());
assert!(detail.merge_requests.is_empty());
// Same state and content are insufficient after correcting a closure.
let stale_correction = case_request(
&closed,
"stale-correction",
json!({"action": "correct_closure", "reason": "duplicate"}),
);
request_json(
app.clone(),
"POST",
&path,
Some(stale_correction),
StatusCode::CONFLICT,
)
.await;
let reopen = case_request(&corrected, "reopen", json!({"action": "reopen"}));
request_json(
app.clone(),
"POST",
&path,
Some(reopen.clone()),
StatusCode::OK,
)
.await;
let reopened = backend.show(reference.id.clone().into()).unwrap();
assert_eq!(reopened.meta.workflow_state, ticket::TicketState::Open);
assert!(reopened.current_closure.is_none());
assert_eq!(
reopened.closure_operation_key,
corrected.closure_operation_key
);
assert_eq!(reopened.case_history.len(), 3);
assert_eq!(ticket::ticket_content_digest(&reopened), digest);
// Exact replay returns current authority, not the old closed response.
for replay in [close.clone(), correction, reopen] {
let result = request_json(app.clone(), "POST", &path, Some(replay), StatusCode::OK).await;
let summary: server_api::TicketSummary = serde_json::from_value(result).unwrap();
assert_eq!(summary.0.workflow_state, ticket::TicketState::Open);
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), reopened);
}
let mut changed_replay = close.clone();
changed_replay["explanation"] = json!("Different judgment under the same operation key");
request_json(
app.clone(),
"POST",
&path,
Some(changed_replay),
StatusCode::CONFLICT,
)
.await;
let mut aba_close = close;
aba_close["operation_key"] = json!("stale-open-close");
request_json(
app.clone(),
"POST",
&path,
Some(aba_close),
StatusCode::CONFLICT,
)
.await;
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), reopened);
let second_close = case_request(
&reopened,
"second-close",
json!({"action": "close", "reason": "not_planned"}),
);
request_json(
app.clone(),
"POST",
&path,
Some(second_close),
StatusCode::OK,
)
.await;
let final_ticket = backend.show(reference.id.into()).unwrap();
assert_eq!(
final_ticket.meta.workflow_state,
ticket::TicketState::Closed
);
assert_eq!(final_ticket.case_history.len(), 4);
assert_eq!(final_ticket.case_history[..3], reopened.case_history);
assert_eq!(
final_ticket.closure_operation_key.as_deref(),
Some("second-close")
);
assert_eq!(ticket::ticket_content_digest(&final_ticket), digest);
}
#[tokio::test]
async fn case_route_rejects_stale_content_state_and_invalid_actions_without_audit_side_effects() {
let temp = tempfile::tempdir().unwrap();
let api = test_api_with_remote_repository(temp.path()).await;
let backend = browser_ticket_backend(&api).unwrap();
let reference = backend
.create(ticket::NewTicket::new("CAS decision"))
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
// Exact replay of the old close is a receipt lookup, not another close.
let response = app
.oneshot(
Request::builder()
.method("POST")
.uri(format!("{path}/workflow/close"))
.header(CONTENT_TYPE, "application/json")
.body(Body::from(close.to_string()))
.unwrap(),
let before = backend.show(reference.id.clone().into()).unwrap();
let app = build_inner_router(api);
let path = format!(
"/workspaces/{TEST_WORKSPACE_ID}/tickets/{}/case",
reference.id
);
let close = case_request(
&before,
"close",
json!({"action": "close", "reason": "unknown"}),
);
for (field, value) in [
("expected_content_digest", json!("stale-content")),
("expected_state", json!("closed")),
("action", json!({"action": "reopen"})),
(
"action",
json!({"action": "correct_closure", "reason": "duplicate"}),
),
] {
let mut invalid = close.clone();
invalid[field] = value;
request_json(
app.clone(),
"POST",
&path,
Some(invalid),
StatusCode::CONFLICT,
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let reopened = backend.show(reference.id.into()).unwrap();
assert_eq!(reopened.meta.workflow_state, TicketWorkflowState::Planning);
assert_eq!(reopened.events.len(), closed.events.len() + 1);
.await;
assert_eq!(backend.show(reference.id.clone().into()).unwrap(), before);
}
request_json(app.clone(), "POST", &path, Some(close), StatusCode::OK).await;
let closed = backend.show(reference.id.clone().into()).unwrap();
let duplicate_close = case_request(
&closed,
"duplicate-close",
json!({"action": "close", "reason": "completed"}),
);
request_json(
app,
"POST",
&path,
Some(duplicate_close),
StatusCode::CONFLICT,
)
.await;
assert_eq!(backend.show(reference.id.into()).unwrap(), closed);
}
@@ -42,17 +42,7 @@ async fn explicit_existing_worker_claim_accepts_no_resources_and_preserves_assig
},
)
.unwrap();
backend
.set_workflow_state(
fixture.ticket_id.clone().into(),
TicketStateChange::new(
"ready",
"planning",
"Explicit investigation",
"No repository resources required",
),
)
.unwrap();
let before = backend.show(fixture.ticket_id.clone().into()).unwrap();
let request = manual_worker_assignment_request(&fixture, "claim-without-resources");
assert!(request.workdir_bindings.is_empty());
let Json(first) = claim(&fixture, request.clone()).await.unwrap();
@@ -67,8 +57,16 @@ async fn explicit_existing_worker_claim_accepts_no_resources_and_preserves_assig
.authority
.ticket(&fixture.ticket_id)
.unwrap()
.state,
"inprogress"
.state
.as_str(),
"open"
);
let after = backend.show(fixture.ticket_id.clone().into()).unwrap();
assert_eq!(after.meta.workflow_state, before.meta.workflow_state);
assert_eq!(after.case_history, before.case_history);
assert_eq!(
ticket::ticket_content_digest(&after),
ticket::ticket_content_digest(&before)
);
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 1);
assert!(
@@ -282,8 +280,9 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re
.authority
.ticket(&fixture.ticket_id)
.unwrap()
.state,
"ready"
.state
.as_str(),
"open"
);
assert!(
fixture
@@ -313,8 +312,9 @@ async fn ambiguous_existing_worker_restore_keeps_claim_pending_until_accepted_re
.authority
.ticket(&fixture.ticket_id)
.unwrap()
.state,
"inprogress"
.state
.as_str(),
"open"
);
assert_eq!(*fixture.runtime.restore_attempts.lock().unwrap(), 3);
assert_eq!(
@@ -435,8 +435,7 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
.is_empty()
);
let backend = browser_ticket_backend(&api).unwrap();
let mut input = ticket::NewTicket::new("Manual investigation without resources");
input.workflow_state = Some(TicketWorkflowState::Ready);
let input = ticket::NewTicket::new("Manual investigation without resources");
let ticket_id = backend.create(input).unwrap().id;
backend
.edit_item(
@@ -447,17 +446,6 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
},
)
.unwrap();
backend
.set_workflow_state(
ticket_id.clone().into(),
TicketStateChange::new(
"ready",
"planning",
"Explicit investigation",
"No repository resources required",
),
)
.unwrap();
let admitted_content_digest = api.authority.ticket(&ticket_id).unwrap().content_digest;
let request = server_api::SetTicketRoleAssignmentRequest {
operation_id: "claim-runtime-reconciliation".into(),
@@ -503,7 +491,10 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
assert!(pending.assignment_id.is_none());
assert_eq!(pending.worker, Some(worker.clone()));
assert!(pending.request_fingerprint.is_some());
assert_eq!(api.authority.ticket(&ticket_id).unwrap().state, "planning");
assert_eq!(
api.authority.ticket(&ticket_id).unwrap().state.as_str(),
"open"
);
assert!(
api.store
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &ticket_id)
@@ -601,8 +592,8 @@ async fn pending_manual_worker_claim_reconciles_same_runtime_restore_and_commits
);
assert_eq!(committed.request_fingerprint, pending.request_fingerprint);
assert_eq!(
api.authority.ticket(&ticket_id).unwrap().state,
"inprogress"
api.authority.ticket(&ticket_id).unwrap().state.as_str(),
"open"
);
assert_eq!(
api.store
@@ -288,17 +288,36 @@ impl LaunchFixture {
fixture
}
fn ticket(&self, state: TicketWorkflowState) -> String {
let mut input =
ticket::NewTicket::new(format!("Explicit Worker launch from {}", state.as_str()));
input.workflow_state = Some(state);
fn ticket(&self) -> String {
browser_ticket_backend(&self.api)
.unwrap()
.create(input)
.create(ticket::NewTicket::new(
"Explicit Worker launch on an open case",
))
.unwrap()
.id
}
fn case_action(&self, ticket_id: &str, operation_key: &str, action: ticket::TicketCaseAction) {
let backend = browser_ticket_backend(&self.api).unwrap();
let before = backend.show(ticket_id.into()).unwrap();
backend
.update_case(
ticket_id,
ticket::TicketCaseUpdate {
expected_content_digest: ticket::ticket_content_digest(&before),
expected_state: before.meta.workflow_state,
expected_closure_operation_key: before.closure_operation_key,
operation_key: operation_key.into(),
action,
explanation: "Explicit fixture case judgment".into(),
references: Vec::new(),
author: Some("fixture".into()),
},
)
.unwrap();
}
fn payload(&self, ticket_id: &str, operation_id: &str, profile: &str, flow: bool) -> Value {
self.backend.publish_profile(&self.api, profile);
let mut segments = vec![
@@ -382,24 +401,40 @@ impl LaunchFixture {
Some(current.assignment_id.as_str())
);
assert_eq!(
self.api.authority.ticket(ticket_id).unwrap().state,
"inprogress"
self.api.authority.ticket(ticket_id).unwrap().state.as_str(),
"open"
);
current
}
}
#[tokio::test(flavor = "multi_thread")]
async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_or_orchestrator() {
async fn embedded_explicit_worker_starts_open_cases_without_phase_transitions_or_queue_requirement()
{
let fixture = LaunchFixture::new(false).await;
for state in [
TicketWorkflowState::Planning,
TicketWorkflowState::Ready,
TicketWorkflowState::Queued,
TicketWorkflowState::InProgress,
] {
let ticket_id = fixture.ticket(state);
let operation_id = format!("explicit-{}", state.as_str());
for context in ["unqueued", "queued", "reopened"] {
let ticket_id = fixture.ticket();
let backend = browser_ticket_backend(&fixture.api).unwrap();
if context == "queued" {
backend
.queue_ready(ticket_id.clone().into(), "fixture")
.unwrap();
} else if context == "reopened" {
fixture.case_action(
&ticket_id,
"fixture-close",
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::NotPlanned,
},
);
fixture.case_action(
&ticket_id,
"fixture-reopen",
ticket::TicketCaseAction::Reopen,
);
}
let before = backend.show(ticket_id.clone().into()).unwrap();
let operation_id = format!("explicit-{context}");
assert!(
fixture
.api
@@ -414,8 +449,16 @@ async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_
);
let payload = fixture.payload(&ticket_id, &operation_id, "builtin:companion", false);
let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await;
assert_eq!(status, StatusCode::OK, "{}: {body}", state.as_str());
assert_eq!(status, StatusCode::OK, "{context}: {body}");
let assignment = fixture.assert_assigned(&ticket_id, &operation_id, &body);
let after = backend.show(ticket_id.clone().into()).unwrap();
assert_eq!(after.meta.workflow_state, before.meta.workflow_state);
assert_eq!(after.case_history, before.case_history);
assert_eq!(after.closure_operation_key, before.closure_operation_key);
assert_eq!(
ticket::ticket_content_digest(&after),
ticket::ticket_content_digest(&before)
);
assert_eq!(body["worker"]["profile"], "builtin:companion", "{body}");
let creates = fixture.backend.creates_for(&assignment.worker.worker_id);
assert_eq!(creates.len(), 1);
@@ -434,7 +477,7 @@ async fn embedded_explicit_worker_starts_each_active_state_without_targets_flow_
async fn real_runtime_http_worker_launch_preserves_optional_flow_profile_submit_and_assignment() {
let fixture = LaunchFixture::new(true).await;
for (profile, flow) in [("builtin:intake", false), ("builtin:coder", true)] {
let ticket_id = fixture.ticket(TicketWorkflowState::Planning);
let ticket_id = fixture.ticket();
let operation_id = format!("real-http-{flow}");
let payload = fixture.payload(&ticket_id, &operation_id, profile, flow);
let expected_submit = payload["initial_submit"].clone();
@@ -510,7 +553,7 @@ async fn worker_launch_uses_published_project_profile_and_workspace_default_on_r
.commit_evaluated_workspace_config(TEST_WORKSPACE_ID, &candidate)
.unwrap();
for explicit in [true, false] {
let ticket_id = fixture.ticket(TicketWorkflowState::Ready);
let ticket_id = fixture.ticket();
let operation_id = format!("project-profile-{explicit}");
let mut payload = fixture.payload(&ticket_id, &operation_id, "project:research", false);
if !explicit {
@@ -532,21 +575,35 @@ async fn worker_launch_uses_published_project_profile_and_workspace_default_on_r
}
#[tokio::test(flavor = "multi_thread")]
async fn completed_or_closed_ticket_launch_rejects_before_runtime_or_assignment_reservation() {
async fn closed_ticket_launch_rejects_before_runtime_or_assignment_reservation() {
let fixture = LaunchFixture::new(false).await;
for state in [TicketWorkflowState::Done, TicketWorkflowState::Closed] {
let ticket_id = fixture.ticket(state);
let operation_id = format!("terminal-{}", state.as_str());
for (label, reason) in [
("completed", ticket::TicketClosureReason::Completed),
("not-planned", ticket::TicketClosureReason::NotPlanned),
] {
let ticket_id = fixture.ticket();
fixture.case_action(
&ticket_id,
"fixture-close",
ticket::TicketCaseAction::Close { reason },
);
let operation_id = format!("terminal-{label}");
let payload = fixture.payload(&ticket_id, &operation_id, "builtin:companion", false);
let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await;
assert_eq!(status, StatusCode::CONFLICT, "{body}");
assert!(
body["message"].as_str().unwrap().contains(state.as_str()),
body["message"].as_str().unwrap().contains("closed"),
"{body}"
);
assert_eq!(
fixture.api.authority.ticket(&ticket_id).unwrap().state,
state.as_str()
fixture
.api
.authority
.ticket(&ticket_id)
.unwrap()
.state
.as_str(),
"closed"
);
assert!(
fixture
@@ -572,7 +629,7 @@ async fn completed_or_closed_ticket_launch_rejects_before_runtime_or_assignment_
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn concurrent_exact_launch_replays_keep_one_worker_and_committed_assignment() {
let fixture = Arc::new(LaunchFixture::new(false).await);
let ticket_id = fixture.ticket(TicketWorkflowState::Planning);
let ticket_id = fixture.ticket();
let payload = fixture.payload(&ticket_id, "concurrent-launch", "builtin:companion", false);
let barrier = Arc::new(tokio::sync::Barrier::new(5));
let mut tasks = Vec::new();
@@ -617,7 +674,7 @@ async fn concurrent_exact_launch_replays_keep_one_worker_and_committed_assignmen
#[tokio::test(flavor = "multi_thread")]
async fn undispatched_launch_rejection_settles_claim_and_allows_corrected_operation() {
let fixture = LaunchFixture::new(false).await;
let ticket_id = fixture.ticket(TicketWorkflowState::Planning);
let ticket_id = fixture.ticket();
let mut rejected = fixture.payload(&ticket_id, "invalid-resources", "builtin:companion", false);
rejected["workdir_attachments"] =
json!([{"alias":"checkout","working_directory_id":"missing-workdir"}]);
@@ -650,7 +707,7 @@ async fn undispatched_launch_rejection_settles_claim_and_allows_corrected_operat
#[tokio::test(flavor = "multi_thread")]
async fn worker_launch_retry_reuses_assignment_and_does_not_repeat_initial_submit() {
let fixture = LaunchFixture::new(false).await;
let ticket_id = fixture.ticket(TicketWorkflowState::Ready);
let ticket_id = fixture.ticket();
let payload = fixture.payload(&ticket_id, "worker-retry", "builtin:companion", false);
let (status, first) = fixture.post(TEST_WORKSPACE_ID, payload.clone()).await;
assert_eq!(status, StatusCode::OK, "{first}");
@@ -703,7 +760,7 @@ async fn worker_launch_retry_reuses_assignment_and_does_not_repeat_initial_submi
async fn generic_ticket_worker_unknown_input_outcome_preserves_assignment_and_launch_retry_identity()
{
let fixture = LaunchFixture::new(false).await;
let ticket_id = fixture.ticket(TicketWorkflowState::Planning);
let ticket_id = fixture.ticket();
let payload = fixture.payload(
&ticket_id,
"worker-unknown-input",
@@ -766,7 +823,7 @@ async fn generic_ticket_worker_unknown_input_outcome_preserves_assignment_and_la
#[tokio::test(flavor = "multi_thread")]
async fn scoped_worker_launch_rejects_foreign_workspace_and_unpublished_profile_with_body() {
let fixture = LaunchFixture::new(false).await;
let ticket_id = fixture.ticket(TicketWorkflowState::Planning);
let ticket_id = fixture.ticket();
let payload = fixture.payload(&ticket_id, "scope-rejected", "builtin:companion", false);
let (status, body) = fixture
.post("019d0000-0000-7000-8000-0000000000bb", payload.clone())
@@ -925,10 +982,10 @@ async fn read_only_ticket_targets_allow_extra_external_grants_without_widening_c
}
#[tokio::test(flavor = "multi_thread")]
async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_and_preserves_planning()
async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_and_preserves_open_case()
{
let fixture = LaunchFixture::new(false).await;
let ticket_id = fixture.ticket(TicketWorkflowState::Planning);
let ticket_id = fixture.ticket();
rusqlite::Connection::open(&fixture.api.config.database_path)
.unwrap()
.execute_batch(
@@ -987,8 +1044,14 @@ async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_an
.is_none()
);
assert_eq!(
fixture.api.authority.ticket(&ticket_id).unwrap().state,
"planning"
fixture
.api
.authority
.ticket(&ticket_id)
.unwrap()
.state
.as_str(),
"open"
);
let counts: (i64, i64) = fixture.api.config_store.with_conn(|conn| Ok((
conn.query_row("SELECT COUNT(*) FROM worker_registry WHERE workspace_id = ?1 AND runtime_id = ?2 AND worker_id = ?3", rusqlite::params![TEST_WORKSPACE_ID, fixture.runtime_id, worker_id], |row| row.get(0))?,
@@ -1000,7 +1063,7 @@ async fn worker_assignment_finalize_failure_compensates_real_embedded_runtime_an
#[tokio::test(flavor = "multi_thread")]
async fn choosing_reviewer_profile_does_not_register_a_reviewer_child_or_grant_mr_review() {
let fixture = LaunchFixture::new(false).await;
let ticket_id = fixture.ticket(TicketWorkflowState::Planning);
let ticket_id = fixture.ticket();
let payload = fixture.payload(&ticket_id, "ordinary-reviewer", "builtin:reviewer", false);
let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await;
assert_eq!(status, StatusCode::OK, "{body}");
@@ -1236,7 +1299,110 @@ async fn uncertain_launch_replay_after_target_edit_keeps_claim_and_blocks_compet
.authority
.ticket(&fixture.ticket_id)
.unwrap()
.state,
"ready"
.state
.as_str(),
"open"
);
}
// Direct policy composition is intentional: this protects routing interest,
// independently of the transport/Runtime launch assertions above.
#[tokio::test(flavor = "multi_thread")]
async fn orchestrator_interest_requires_queue_receipt_or_unfinished_work_not_just_open_state() {
let fixture = LaunchFixture::new(false).await;
let backend = browser_ticket_backend(&fixture.api).unwrap();
let queued_id = fixture.ticket();
assign_test_orchestrator(&fixture.api, &queued_id);
assert!(
orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open")
.unwrap()
.is_none()
);
backend
.queue_ready(queued_id.clone().into(), "fixture")
.unwrap();
assert!(
orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open")
.unwrap()
.is_some()
);
fixture.case_action(
&queued_id,
"queue-case-close",
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Completed,
},
);
assert!(
orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "closed")
.unwrap()
.is_none()
);
fixture.case_action(
&queued_id,
"queue-case-reopen",
ticket::TicketCaseAction::Reopen,
);
assert!(
orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &queued_id, "open")
.unwrap()
.is_none()
);
let active_id = fixture.ticket();
assign_test_orchestrator(&fixture.api, &active_id);
let payload = fixture.payload(
&active_id,
"routing-active-work",
"builtin:companion",
false,
);
let (status, body) = fixture.post(TEST_WORKSPACE_ID, payload).await;
assert_eq!(status, StatusCode::OK, "{body}");
fixture.assert_assigned(&active_id, "routing-active-work", &body);
let active = backend.show(active_id.clone().into()).unwrap();
assert!(
active
.events
.iter()
.all(|event| !event.attributes.contains_key("queue_root_ticket"))
);
assert!(
orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &active_id, "open")
.unwrap()
.is_some()
);
fixture.case_action(
&active_id,
"active-case-close",
ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::NotPlanned,
},
);
fixture.case_action(
&active_id,
"active-case-reopen",
ticket::TicketCaseAction::Reopen,
);
assert!(
fixture
.api
.store
.get_current_ticket_worker_assignment(TEST_WORKSPACE_ID, &active_id)
.unwrap()
.is_some()
);
assert!(
fixture
.api
.store
.get_active_ticket_worker_assignment(TEST_WORKSPACE_ID, &active_id)
.unwrap()
.is_none()
);
assert!(
orchestrator_interested(&fixture.api, TEST_WORKSPACE_ID, &active_id, "open")
.unwrap()
.is_none()
);
}
File diff suppressed because it is too large Load Diff
+107 -95
View File
@@ -1266,9 +1266,8 @@ mod tests {
#[tokio::test]
async fn terminal_assignments_release_unfinished_work_guard_but_not_attachment_or_hold() {
for (status, workflow_state) in [("closed", "closed"), ("open", "done")] {
let (store, workdir) = seeded_store().await;
store
let (store, workdir) = seeded_store().await;
store
.with_conn(|conn| {
conn.execute(
"INSERT INTO worker_registry (
@@ -1288,7 +1287,7 @@ mod tests {
workspace_id, ticket_id, slug, title, status, kind, priority, body,
workflow_state, workflow_state_explicit
) VALUES ('workspace-a', 'ticket-a', 'ticket-a', 'Ticket A', 'open',
'task', 'normal', '', 'inprogress', 1)",
'task', 'normal', '', 'open', 1)",
[],
)?;
conn.execute(
@@ -1307,59 +1306,58 @@ mod tests {
Ok(())
})
.unwrap();
let intent =
workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap();
let operation = store.reserve_workdir_removal_operation(&intent).unwrap();
let categories = |guards: Vec<WorkdirRemovalGuard>| {
guards
.into_iter()
.map(|guard| guard.category)
.collect::<Vec<_>>()
};
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment", "unfinished_work", "retention_hold"]
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET status=?1, workflow_state=?2
let intent = workdir_removal_intent(&workdir, "workspace-api", "remove Workdir").unwrap();
let operation = store.reserve_workdir_removal_operation(&intent).unwrap();
let categories = |guards: Vec<WorkdirRemovalGuard>| {
guards
.into_iter()
.map(|guard| guard.category)
.collect::<Vec<_>>()
};
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment", "unfinished_work", "retention_hold"]
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET status='closed', workflow_state='closed'
WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'",
params![status, workflow_state],
)?;
Ok(())
})
.unwrap();
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment", "retention_hold"]
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET status='open', workflow_state='planning'
[],
)?;
Ok(())
})
.unwrap();
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment", "retention_hold"]
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE typed_tickets SET status='open', workflow_state='open'
WHERE workspace_id='workspace-a' AND ticket_id='ticket-a'",
[],
)?;
Ok(())
})
.unwrap();
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment", "retention_hold"],
"reopening must not restore unfinished work on the retained assignment"
);
assert!(matches!(
store.commit_workdir_removal_removed(&operation),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(
store
.get_workdir_registry("workspace-a", "workdir-a")
.unwrap()
.is_some()
);
[],
)?;
Ok(())
})
.unwrap();
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment", "retention_hold"],
"reopening must not restore unfinished work on the retained assignment"
);
assert!(matches!(
store.commit_workdir_removal_removed(&operation),
Err(Error::WorkdirAttachmentConflict(_))
));
assert!(
store
.get_workdir_registry("workspace-a", "workdir-a")
.unwrap()
.is_some()
);
store
.with_conn(|conn| {
conn.execute(
"UPDATE worker_registry SET retention_state='normal'
@@ -1369,49 +1367,63 @@ mod tests {
Ok(())
})
.unwrap();
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment"]
);
assert!(matches!(
store.commit_workdir_removal_removed(&operation),
Err(Error::WorkdirAttachmentConflict(_))
));
store
.with_conn(|conn| {
conn.execute(
"UPDATE worker_workdir_links SET unlinked_at='2'
assert_eq!(
categories(store.workdir_removal_guards(&operation).unwrap()),
vec!["active_attachment"]
);
assert!(matches!(
store.commit_workdir_removal_removed(&operation),
Err(Error::WorkdirAttachmentConflict(_))
));
store
.with_conn(|conn| {
conn.execute(
"UPDATE worker_workdir_links SET unlinked_at='2'
WHERE workspace_id='workspace-a' AND workdir_id='workdir-a'",
[],
)?;
Ok(())
})
.unwrap();
assert!(store.workdir_removal_guards(&operation).unwrap().is_empty());
let completed = store.commit_workdir_removal_removed(&operation).unwrap();
assert_eq!(
completed.disposition,
Some(WorkdirRemovalDisposition::Removed)
);
assert!(
store
.get_workdir_registry("workspace-a", "workdir-a")
.unwrap()
.is_none()
);
let retained: i64 = store
.with_conn(|conn| {
conn.query_row(
"SELECT COUNT(*) FROM ticket_current_worker_assignments
[],
)?;
Ok(())
})
.unwrap();
assert!(store.workdir_removal_guards(&operation).unwrap().is_empty());
let completed = store.commit_workdir_removal_removed(&operation).unwrap();
assert_eq!(
completed.disposition,
Some(WorkdirRemovalDisposition::Removed)
);
assert!(
store
.get_workdir_registry("workspace-a", "workdir-a")
.unwrap()
.is_none()
);
let retained: i64 = store
.with_conn(|conn| {
conn.query_row(
"SELECT COUNT(*) FROM ticket_current_worker_assignments
WHERE workspace_id='workspace-a' AND assignment_id='assignment-a'",
[],
|row| row.get(0),
)
.map_err(Error::from)
})
.unwrap();
assert_eq!(retained, 1);
}
[],
|row| row.get(0),
)
.map_err(Error::from)
})
.unwrap();
assert_eq!(retained, 1);
let released: i64 = store
.with_conn(|conn| {
conn.query_row(
"SELECT COUNT(*) FROM ticket_assignment_work_releases
WHERE workspace_id='workspace-a' AND assignment_id='assignment-a'",
[],
|row| row.get(0),
)
.map_err(Error::from)
})
.unwrap();
assert_eq!(
released, 1,
"reopening and cleanup must preserve the work release"
);
}
#[tokio::test]
@@ -52,6 +52,9 @@ const WORKSPACE_DELETION_PURGE_TABLES: &[&str] = &[
"ticket_current_worker_assignments",
"ticket_worker_assignment_events",
"ticket_worker_assignments",
"typed_ticket_case_history",
"typed_ticket_case_metadata",
"typed_ticket_legacy_workflow_snapshot",
"typed_ticket_artifacts",
"typed_ticket_event_attributes",
"typed_ticket_event_references",
@@ -457,6 +460,26 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
}
let workspace_id = operation.response.workspace_id.clone();
// Case judgments and legacy snapshots are immutable under ordinary
// Ticket operations, including Ticket deletion and FK cascades. Only
// this owner-authorized whole-Workspace purge may discard them.
// SQLite transactional DDL holds the writer lock: no concurrent writer
// can observe the suspended guards, and any error rolls back both
// data and schema. Restore the exact schema-owned definitions before
// commit; update guards remain installed throughout.
let mut case_delete_guards = Vec::new();
for (table, trigger) in [
("typed_ticket_case_history", "typed_ticket_case_history_no_delete"),
("typed_ticket_legacy_workflow_snapshot", "typed_ticket_legacy_workflow_no_delete"),
] {
let definition: String = tx.query_row(
"SELECT sql FROM sqlite_master WHERE type='trigger' AND name=?1 AND tbl_name=?2",
params![trigger, table],
|row| row.get(0),
)?;
tx.execute_batch(&format!("DROP TRIGGER \"{trigger}\";"))?;
case_delete_guards.push(definition);
}
for table in WORKSPACE_DELETION_PURGE_TABLES {
tx.execute(
&format!("DELETE FROM \"{table}\" WHERE workspace_id = ?1"),
@@ -472,6 +495,9 @@ impl WorkspaceDeletionStore for SqliteWorkspaceStore {
"Workspace disappeared before deletion finalized".to_string(),
));
}
for definition in case_delete_guards {
tx.execute_batch(&definition)?;
}
let now = Utc::now().to_rfc3339();
tx.execute(
"UPDATE workspace_deletion_operations
@@ -874,8 +900,10 @@ mod tests {
use tempfile::tempdir;
fn setup() -> (SqliteWorkspaceStore, String, String) {
let dir = tempdir().expect("tempdir");
let store = SqliteWorkspaceStore::open(dir.path().join("server.db")).expect("store");
seed_workspaces(SqliteWorkspaceStore::in_memory().expect("store"))
}
fn seed_workspaces(store: SqliteWorkspaceStore) -> (SqliteWorkspaceStore, String, String) {
store.with_conn(|conn| {
let now = Utc::now().to_rfc3339();
conn.execute(
@@ -931,6 +959,191 @@ mod tests {
.expect("purge inventory");
}
#[test]
fn workspace_purge_preserves_other_cases_and_restores_immutability_after_commit_or_rollback() {
use ticket::TicketBackend;
for inject_failure in [false, true] {
let dir = tempdir().expect("tempdir");
let path = dir.path().join("server.db");
let (store, owner, workspace_id) =
seed_workspaces(SqliteWorkspaceStore::open(&path).expect("store"));
for workspace in ["workspace-a", "workspace-b"] {
let backend = ticket::SqliteTicketBackend::open_verified(&path, workspace).unwrap();
let initial = backend
.create_with_snapshot(ticket::NewTicket::new("Case evidence"))
.unwrap()
.1;
backend
.update_case(
&initial.meta.id,
ticket::TicketCaseUpdate {
expected_content_digest: ticket::ticket_content_digest(&initial),
expected_state: initial.meta.workflow_state,
expected_closure_operation_key: initial.closure_operation_key,
operation_key: "close".into(),
action: ticket::TicketCaseAction::Close {
reason: ticket::TicketClosureReason::Completed,
},
explanation: "Requested result satisfied".into(),
references: vec![],
author: Some("owner".into()),
},
)
.unwrap();
// Seed only the opaque retained snapshot for the deletion boundary;
// migration and legacy phase interpretation are tested by the store.
let snapshot = ticket::TicketLegacyWorkflowSnapshot {
workflow_state: "closed".into(),
workflow_state_explicit: true,
status: "closed".into(),
queued_by: None,
queued_at: None,
resolution: None,
};
store
.with_conn(|conn| {
conn.execute(
"INSERT INTO typed_ticket_legacy_workflow_snapshot
(workspace_id,ticket_id,snapshot_json) VALUES (?1,?2,?3)",
params![
workspace,
initial.meta.id,
serde_json::to_string(&snapshot).unwrap()
],
)?;
Ok(())
})
.unwrap();
}
let assert_immutable = |workspace: &str| {
store.with_conn(|conn| {
for table in ["typed_ticket_case_history", "typed_ticket_legacy_workflow_snapshot"] {
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("DELETE FROM typed_tickets WHERE workspace_id=?1", [workspace]).is_err(),
"ordinary Ticket deletion must not cascade away case evidence");
Ok(())
}).unwrap();
};
assert_immutable("workspace-a");
assert_immutable("workspace-b");
let preflight = store
.workspace_deletion_preflight(&owner, &workspace_id)
.unwrap();
let operation = store
.reserve_workspace_deletion(
&owner,
&workspace_id,
&WorkspaceDeletionRequest {
operation_id: "delete-cases".into(),
expected_workspace_updated_at: preflight.expected_workspace_updated_at,
confirmation: "Alpha".into(),
},
)
.unwrap()
.operation;
let tables = [
"typed_ticket_case_history",
"typed_ticket_case_metadata",
"typed_ticket_legacy_workflow_snapshot",
];
let other_before: Vec<String> = store
.with_conn(|conn| {
tables
.iter()
.map(|table| {
conn.query_row(
&format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"),
[],
|row| row.get(row.as_ref().column_count() - 1),
)
.map_err(Into::into)
})
.collect()
})
.unwrap();
if inject_failure {
store
.with_conn(|conn| {
conn.execute_batch(
"CREATE TRIGGER reject_workspace_purge BEFORE DELETE ON workspaces
BEGIN SELECT RAISE(ABORT, 'injected purge failure'); END;",
)?;
Ok(())
})
.unwrap();
assert!(
store
.finalize_workspace_deletion(&operation.operation_id)
.is_err()
);
assert_immutable("workspace-a");
assert_immutable("workspace-b");
store
.with_conn(|conn| {
for table in tables {
let count: i64 = conn.query_row(
&format!(
"SELECT COUNT(*) FROM {table} WHERE workspace_id='workspace-a'"
),
[],
|row| row.get(0),
)?;
assert_eq!(count, 1, "rollback must restore {table}");
}
conn.execute_batch("DROP TRIGGER reject_workspace_purge;")?;
Ok(())
})
.unwrap();
assert_ne!(
store
.workspace_deletion_operation_for_recovery(&operation.operation_id)
.unwrap()
.unwrap()
.state,
WorkspaceDeletionState::Succeeded
);
}
let completed = store
.finalize_workspace_deletion(&operation.operation_id)
.unwrap();
assert_eq!(completed.state, WorkspaceDeletionState::Succeeded);
assert_eq!(
store
.finalize_workspace_deletion(&operation.operation_id)
.unwrap(),
completed
);
assert_immutable("workspace-b");
store
.with_conn(|conn| {
for (table, before) in tables.iter().zip(&other_before) {
let count: i64 = conn.query_row(
&format!(
"SELECT COUNT(*) FROM {table} WHERE workspace_id='workspace-a'"
),
[],
|row| row.get(0),
)?;
assert_eq!(count, 0, "purge must remove {table}");
let after: String = conn.query_row(
&format!("SELECT * FROM {table} WHERE workspace_id='workspace-b'"),
[],
|row| row.get(row.as_ref().column_count() - 1),
)?;
assert_eq!(
&after, before,
"other Workspace {table} evidence must remain unchanged"
);
}
Ok(())
})
.unwrap();
}
}
#[test]
fn deletion_is_idempotent_and_removes_workspace_scoped_rows() {
let (store, owner, workspace_id) = setup();
File diff suppressed because it is too large Load Diff