feat: separate Ticket case state and immutable closure judgments
This commit is contained in:
@@ -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(¤t),
|
||||
expected_state: parse(&change.from)?,
|
||||
state: parse(&change.to)?,
|
||||
reason: if change.body.as_str().is_empty() {
|
||||
change.reason.clone()
|
||||
} else {
|
||||
format!("{}\n\n{}", change.reason, change.body.as_str())
|
||||
},
|
||||
references: change.references.clone(),
|
||||
author: change.author.clone(),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn set_ticket_workflow_state(
|
||||
&self,
|
||||
id: &TicketIdOrSlug,
|
||||
change: &TicketStateChange,
|
||||
_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(¤t),
|
||||
expected_state: current.meta.workflow_state,
|
||||
reason: resolution.as_str().to_string(),
|
||||
references: Vec::new(),
|
||||
author: None,
|
||||
};
|
||||
let workspace_id = self.workspace_id.clone();
|
||||
let id = ticket_reference(id);
|
||||
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"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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!(
|
||||
|
||||
@@ -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;
|
||||
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
File diff suppressed because it is too large
Load Diff
@@ -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
@@ -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);
|
||||
@@ -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
@@ -796,7 +796,7 @@ mod tests {
|
||||
"ReviewMergeRequest",
|
||||
"CheckMergeRequestReadiness",
|
||||
"CompleteMergeRequest",
|
||||
"CompleteTicket",
|
||||
"TicketCaseUpdate",
|
||||
] {
|
||||
assert!(combined.contains(operation), "missing {operation}");
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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
@@ -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();
|
||||
|
||||
+805
-122
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user