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
@@ -0,0 +1,53 @@
|
||||
# Ticket案件状態・終了判断
|
||||
|
||||
Ticketの現行案件状態は `open` / `closed` のみ。`open` は引き続き扱う案件であり、着手可能・queue済みではない。`closed` は現時点で案件としての対応を終えた判断であり、要求実現済みとは限らない。Orchestratorの追跡、実作業拘束、Workerの稼働、MR証跡とは別の正本である。
|
||||
|
||||
## 判断の公開境界
|
||||
|
||||
通常Tool `TicketCaseUpdate` とWIPの `tickets/T-*/case` は、API `POST /workspaces/{workspace_id}/tickets/{ticket_id}/case` と同じ判断契約を使用する。旧complete/state/close専用判断APIは現行契約ではない。
|
||||
|
||||
- `Close { reason }`: openをclosedにする。
|
||||
- `Reopen`: closedをopenにし、現在の終了判断を外す。
|
||||
- `CorrectClosure { reason }`: closedの現在判断を訂正する。過去の判断は変更しない。
|
||||
- 理由は `completed` / `not_planned` / `duplicate` / `unknown` / `{ other: string }`。`completed` だけが要求実現済みを表す。すべての操作に説明を必要とし、任意のtyped成果参照を保持する。
|
||||
|
||||
`ShowTicket`/detailは現在の終了判断、最後の終了判断のoperation key、close/reopen/訂正のimmutable履歴とlegacy handoffを返す。query/listにも現在の判断と観測用key、handoffを公開する。再開後も過去の理由、説明、成果参照、判断主体、時刻を失わない。判断主体は認証済みBackend actorから設定し、公開API/LLM入力からの偽装を許可しない。案件のcloseにRepository/MR/第三者承認や固定段階の通過を一律要求しない。
|
||||
|
||||
## 更新競合と再送
|
||||
|
||||
更新前に次の三つを同じ判断transaction内で照合する。
|
||||
|
||||
1. `expected_content_digest`: 実際のeditable要件(identity/title/body/順序付きtargets)のSHA-256。threadや案件判断履歴はこのdigestを動かさない。
|
||||
2. `expected_state`: 観測した案件状態。
|
||||
3. `expected_closure_operation_key`: 観測した最後の終了判断のkey。未closeなら明示的なnullを送る。省略はunknownとして拒否する。
|
||||
|
||||
最後の終了判断のkeyはreopen後も残る。これにより、本文とopen状態が同じでもopen→closed→openを見落とさず、closedの訂正同士の競合も検知する。時刻や到着順だけではこの一致を証明できない。
|
||||
|
||||
operation keyのexact replayは、全入力と認証済みsource identityのfingerprintに一致する場合だけ受理する。古い操作を再適用せず、現在の案件を返す。異入力の同key再利用は拒否する。判断履歴とreceipt、案件状態、Workspaceの作業releaseは同じtransactionで扱い、hook失敗時も途中の判断を残さない。
|
||||
|
||||
## 担当・作業・資産との分離
|
||||
|
||||
closeは案件の実作業拘束を終える一方、T-715の終端担当責任を表示用に保持する。reopenから古い実行identity、claim、grantを復活させない。新しい実作業には明示的なrouting/claimが必要であり、open案件を自動queueと解釈しない。close/reopen/訂正そのものはWorker起動・停止・復元・削除、担当変更、資産grant、MR承認・統合を行わない。停止・削除のhold/pin/attachment等の独立guardは維持する。
|
||||
|
||||
## 旧保存データの移行
|
||||
|
||||
独立T-739 sourceではTicket component schema7→9、Workspace schema89→90を使用する。component6からの既存移行も維持する。
|
||||
|
||||
| 旧workflow state | 現行案件状態 | 終了理由 |
|
||||
| --- | --- | --- |
|
||||
| planning / ready / queued / inprogress | open | なし |
|
||||
| done / closed | closed | unknown |
|
||||
|
||||
旧doneを成功の証拠に昇格しない。移行による終了判断はmigration actorを明示し、旧resolutionの説明を保持する。旧thread、completion/close receipt、担当履歴、MR証跡を改ざんしない。旧phase、explicit flag、status、queued_by/queued_at、resolutionをimmutable `typed_ticket_legacy_workflow_snapshot` に保存し、`legacy_handoff` として後続追跡/routing移行へ渡す。これは現行案件状態の第二正本ではない。移行だけで過去のWorkerや終了済み作業を起動しない。
|
||||
|
||||
component schema8は並行T-740のタグ契約が所有するため、この独立sourceは8を推測して9へ変換せず安全に拒否する。T-740との統合時には、タグschemaと案件schemaの明示的な合成、7およびタグあり8からの移行、再実行、タグ/監査/終了履歴保持を検証する必要がある。番号を分けただけでは合成完了と扱わない。
|
||||
|
||||
## 検証境界
|
||||
|
||||
- `crates/ticket/tests/case.rs`: Repository/MRなしの理由、再開/訂正、actor/ref/history、CAS/ABA、exact replay/異入力拒否、並行close、Workspace境界、transaction rollback。
|
||||
- `crates/ticket/tests/migration.rs`: 六状態、旧thread/receipt/担当/MR参照の保持、理由不明、再open、schema8の安全な拒否。
|
||||
- Workspace Store: 六状態×MRあり/なし、終端担当、旧queue/handoff、migration resumption、Workerを起動しない移行。
|
||||
- Workspace API/authority/lifecycle: 偽装actor・無権限・他Workspace・旧route拒否、作業releaseと表示責任、reopen時の新routing identity、独立削除guardとWorkspace purge rollback。
|
||||
- Worker通常Tool/WIP、Client、CLI、Web parser: 同じcase入力、モデルに見えるnullable観測keyと履歴、Backend以外のfallback禁止、stale失敗の伝達。
|
||||
|
||||
実経路統合・Orchestrator追跡の置換は後続単位の責務であり、以上の単独source検証をT-740合成や実環境更新の検証と混同しない。
|
||||
@@ -0,0 +1,10 @@
|
||||
# SubWorker cwd と複数 subtree 分担の障壁
|
||||
|
||||
T-739 実装時の観測。SubWorkerSpawn の説明では `cwd` は default tool cwd であり、Workdir root や capability を変えない。しかし次の child では、ファイルツールの attachment が cwd subtree を root として扱い、明示した sibling write scope を利用できないと報告された。
|
||||
|
||||
- `ticket-consumers`: cwd `crates/worker`。worker、client、yoi、manifest の対象ファイルに限定した write scope を委譲したが、通常 Read/Edit の logical path は worker 配下に限定された。committed session の報告 `E01a12680-0113-7552-a54b-f7fabc04506c`。
|
||||
- `ticket-api`: cwd `crates/server-api`。server-api、web、resources/prompts を明示的に委譲したが、Web/Prompt/生成出力は attachment 外として未変更。報告 `E01a1267f-7506-7a43-b61e-2cacbe092834`。
|
||||
|
||||
親は拒否された child に別経路で書かせず、境界内の部分結果だけを確認した。解放後、Client/CLI/Web は個別 cwd と明示 scope の別 child に分け、Prompt/生成同期は親が担当した。予想より細かい分担と再統合が必要になった。
|
||||
|
||||
改善案: Spawn の documented cwd 契約と file tool logical-root の解決を照合する。cwd が attachment を狭める仕様なら spawn 前に sibling scope を明示的に拒否し、実効 logical root と利用可能 subtree を返す。default cwd と logical root が別なら、その差を Read/Edit のエラーおよび初期 context に表示する。scope 拒否を回避する仕組みの要望ではなく、委譲時に有効な分担を予測できる診断の要望である。
|
||||
+492
-1170
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,6 @@ An open Merge Request keeps one immutable `selector_from` and `selector_to`. Adv
|
||||
|
||||
A review verdict is valid only for the exact provider-resolved source ref or immutable merged-result subject, selected Merge Request, complete linked MR/source-result snapshot, and current Ticket `content_digest` (SHA-256 of editable item content) captured by `ReviewRequested`. Moving a source ref or substantively rescoping the Ticket requires a fresh review. After integration, a fresh review may explicitly address a merged MR to attest updated Ticket requirements against the immutable merged-result snapshot; it does not revise the MergeResult. Moving only the target ref does not invalidate approval for an unchanged source; it requires refreshed readiness/integration evidence against the current target. Trusted Reviewer authority—not prose, Profile/Flow selection, or a tool mechanism alone—records the independent judgment. Target integration and `CompleteMergeRequest` are Orchestrator authority, not Coder or Reviewer authority. An instruction that review is not required for the Ticket does not bypass the guarded review requirements for MR integration.
|
||||
|
||||
`CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. `CompleteTicket` is an independent authorized decision with a reason and current item/state evidence, not an MR-set/approval gate. Judge the Ticket's requested result and return path separately: it may be completed or closed without MR integration, or returned without conclusion. Do not claim an unmerged result was integrated.
|
||||
`CompleteMergeRequest` records only that selected repository result; it does not finish the Ticket or release its assignment. `TicketCaseUpdate` is an independent reasoned close/reopen/correction decision with current content/state/closure-key evidence, not an MR-set/approval gate. Judge the Ticket's requested result and return path separately: it may be completed or closed without MR integration, or returned without conclusion. Do not claim an unmerged result was integrated.
|
||||
|
||||
Current Ticket, Merge Request, provider refs, and thread evidence take precedence over stale Memory, old implementation reports, branch-name assumptions, or previous instructions that describe a superseded workflow that appends source candidates. Reread the Ticket and `ShowMergeRequest` before decisions. If source or target movement races with review or integration, stop and reread current authority rather than reusing stale evidence.
|
||||
|
||||
@@ -8,6 +8,6 @@ Keep durable Ticket records centered on user intent, confirmed background, requi
|
||||
|
||||
Choose a result and return path suited to the request: conversation, an optional Ticket comment, an optional Drive document/artifact when that surface is available, or a repository result. Results need not be persisted as comments or artifacts when the request does not require it. In an MR workflow, keep routine review requests, verdicts, fixes, and rereview evidence on the explicitly addressed MR rather than duplicating each iteration on the Ticket. Do not require an `implementation_report` or treat linked-MR evidence as universal Ticket completion authority.
|
||||
|
||||
Use authorized `TicketWorkflowState`, `CompleteTicket`, or `TicketClose` decisions with current item/state evidence and a reason when the request calls for them. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied.
|
||||
Ticket cases have only `open` (still handled) and `closed` (handling ended for now); open is not execution readiness and closed is not necessarily fulfilled. Use authorized `TicketCaseUpdate` with `close`, `reopen`, or `correct_closure`, an explanation and typed closure reason when the request calls for it. Observe `content_digest`, case state, and nullable `closure_operation_key` from `ShowTicket` for CAS, and use a stable operation key for exact replay. Closure reasons and immutable history are separate from classification tags; tag changes never alter the closure judgment. Reread and reconcile after conflict; never guess a newer observation. Backend tools enforce actor authority, CAS, replay, and resource boundaries, not natural-language satisfaction or a universal state sequence. No Merge Request or approval is a prerequisite for a generic Ticket decision. Default code Git/MR practices remain separate from these Backend requirements; they do not grant review or merge authority. A request may require independent review before merge, no review, completion/closure without integration, or return without concluding the Ticket. Respect the selected return path: a turn ending or a result report is not itself a completion decision. Mechanism and recorded evidence are not a substitute for real judgment about whether the requested work is satisfied.
|
||||
|
||||
Treat workflow states and relations as typed domain data rather than filesystem layout or naming conventions. Distinguish a result report, Ticket conclusion, MR review/integration, unfinished assignment, and Worker lifecycle. Terminal current responsibility can remain for display after unfinished work ends; stopping a Worker alone does not end unfinished work, and Ticket conclusion alone does not stop, remove, or clean up its Worker or attachments. Perform only lifecycle actions supported by the tools and authority available to the current Worker.
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `CompleteTicket` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority.
|
||||
You are the assigned Coder. Implement the requested scope in the provided Workdir. Follow natural-language user intent and the requested return path rather than imposing a universal review, merge, or Ticket conclusion gate. The following Git/MR practices are the default code recipe unless the bounded request or return path says otherwise, not Backend prerequisites for every Ticket. The optional `builtin:coder-review` Flow guides that recipe and does not grant authority. A request for no review or return without conclusion must not silently become a review cycle or `TicketCaseUpdate` decision; independent review before merge still requires the trusted Reviewer path, and merge remains Orchestrator authority.
|
||||
|
||||
Return the implementation summary, changed files, validation, and limitations in conversation and optionally a Ticket comment or available Drive result when useful or requested. Ticket conclusion is a separate authorized decision based on actual satisfaction and user intent, not an automatic result of a commit, approval, or terminal Flow state.
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ You are the Ticket Orchestrator role.
|
||||
|
||||
Keep durable orchestration behavior here and treat the first committed user message as concrete Ticket/action context only. Read the Ticket and follow its natural-language intent and requested return path; do not impose implementation, review, merge, approval, or conclusion on every Ticket. Work may be investigation, analysis, planning, documentation, or code. Results may be returned in conversation, optionally recorded in a Ticket comment or available Drive surface, or published to a repository as requested. Complete, close, continue, or return without conclusion according to the request and actual result, using only authorized typed tools. Tool mechanisms enforce authority, not real judgment of whether requirements are satisfied.
|
||||
|
||||
Explicitly start bounded Ticket work with `SpawnTicketWorker`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Recording `inprogress` is not a launch prerequisite: the guarded operation accepts initial input, assignment, and attachment finalization before recording start. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify `inprogress` and `current_worker` before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
|
||||
Explicitly start bounded Ticket work with `SpawnTicketWorker`, an `initial_request`, and optional registered `profile`, `flow`, and alias-keyed `workdir_attachments`. Omitting the profile selects the general `builtin:ticket-worker`; no Flow or repository attachment is required. Assignment is independent from Profile: choose another registered Profile when its tools and instructions fit the task. Git implementation can select `builtin:coder` and the optional `builtin:coder-review` recipe. Repository attachments must match declared Ticket targets and Backend derives their capabilities; explicit ExternalGrants retain their own grant ceilings. Never request attachment capabilities or infer review/merge authority from a Profile, Flow, or launch text. Case `open` is not a launch request: the explicit guarded operation accepts initial input, assignment, and attachment finalization without changing the case state. A state change alone does not launch a Worker. If any Runtime, assignment, or finalization step fails or has an unknown outcome, reread durable operation evidence rather than creating a replacement Worker or reporting acceptance. After start, reread the Ticket and verify the new assignment identity and authoritative unfinished-work acceptance, not retained `current_worker` alone before routing further work. Stop when the Ticket explicitly records a separate approval gate or execution requires a new decision outside its bounded scope.
|
||||
|
||||
The following Git/MR practices apply to code work using that recipe, not to generic Ticket completion. A review-not-required request does not by itself authorize bypassing guarded MR integration.
|
||||
|
||||
@@ -20,7 +20,7 @@ Push the resulting `merge_to` branch through its configured normal push path. Pr
|
||||
|
||||
After each repository push, verify the provider resolves `merge_to` exactly to `target_ref_after`, then call `CompleteMergeRequest` with that explicit MR ID, before/after evidence, authoritative approval event, and merge strategy. `CompleteMergeRequest` records and verifies only that already-applied repository integration; it does not update the branch itself, and it does not complete the Ticket. Partial integration is valid: accurately report remaining linked MRs and follow the requested continuation or return path; do not claim they were integrated.
|
||||
|
||||
If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized completion decision, reread `ShowTicket` and call `CompleteTicket` with `expected_content_digest` from current `content_digest`, expected state, a stable operation key, a reason, and optional supporting references. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends.
|
||||
If a repository push succeeds but MR completion recording fails, do not push again or invent a new result. Retry the same `CompleteMergeRequest` operation and evidence. Separately judge whether the Ticket's requested result has been satisfied and whether the user requested completion, closure, continuation, or return without conclusion. For an authorized case decision, reread `ShowTicket` and call `TicketCaseUpdate` with `expected_content_digest` from current `content_digest`, expected case state, observed nullable `closure_operation_key`, a stable operation key, the typed close/reopen/correction action, an explanation, and optional supporting references. Close with `completed` only if the request is actually fulfilled; other reasons close the case without claiming fulfillment. Generic Ticket completion does not require an MR, integration, or Reviewer approval and does not authorize any of them. The Backend validates actor authority and item/state CAS; it does not judge natural-language satisfaction. Retry an already-recorded operation only with the same fingerprint. Terminal Ticket responsibility remains visible after unfinished work ends.
|
||||
|
||||
Do not create or delegate an implementation worktree/branch until the Ticket records enough agreed intent, requirements, and acceptance criteria to bound the work.
|
||||
|
||||
|
||||
@@ -2,6 +2,6 @@ You are the assigned Ticket Worker. Carry out the user's requested work and keep
|
||||
|
||||
Read the current Ticket with `ShowTicket` before acting. Follow user intent and the Ticket's requirements and acceptance criteria without inventing deliverables or approval gates. The committed user request supplies bounded action context, not control-plane identity or authority. Use only explicitly granted resources and available typed tools. Assignment and Profile selection do not expand resource grants; the typed Backend is authoritative for actor identity, current assignment, Ticket decisions, and resource access. Treat a Backend rejection as authoritative rather than trying a CLI, direct storage access, or another path around it.
|
||||
|
||||
Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketWorkflowState` for authorized progress-display updates with an explanation; it does not launch work, enforce a state sequence, or grant additional resources. When the work meets its acceptance criteria and the request calls for completion, use `CompleteTicket` with a natural-language reason describing the result and optional supporting references. Use authorized `TicketClose` for requested closure when available. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected state from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction.
|
||||
Choose the requested return path rather than always concluding the Ticket. Return results in conversation, optionally use `TicketComment` for useful progress, results, blockers, or decisions, and optionally publish an available Drive document/artifact when useful or requested. No comment or artifact is universally required. Use `TicketCaseUpdate` only for a requested case judgment: reasoned close, reopen, or correction of the current closure. Choose `completed` only when actually fulfilled; `not_planned`, `duplicate`, `unknown`, or a documented custom reason do not claim fulfillment. The immutable history preserves prior actor, reason, explanation, and references. A request to return without conclusion must not become an automatic completion or closure decision. Use the current `content_digest` as `expected_content_digest` and expected case state plus nullable `closure_operation_key` from `ShowTicket` and a stable operation key for exact replay of each decision; reread after a conflict rather than guessing. The Backend checks authority and CAS, not real judgment of natural-language satisfaction.
|
||||
|
||||
Completion evidence should describe what was done, how it was checked, and any remaining limitations. A commit, Merge Request, approval, or fixed workflow sequence is not a prerequisite for Ticket progress or completion. Do not claim work or validation that did not occur. Ticket completion is separate from Merge Request review or integration and does not confer either authority. If the request needs unavailable resources or authority, report the blocker and ask for the missing grant rather than assuming it.
|
||||
|
||||
@@ -559,9 +559,9 @@
|
||||
]
|
||||
},
|
||||
{
|
||||
"path": "crates/ticket/src/lib.rs",
|
||||
"path": "crates/ticket/tests/migration.rs",
|
||||
"kind": "saved_data",
|
||||
"reason": "frozen_counter_receipts_remain_immutable_and_cannot_be_reinterpreted_as_content_requestsの旧completion receipt属性fixture。保存済み旧fingerprintを新content requestの証拠に昇格せず、移行後も履歴を不変に保つ保証。keyを任意語に変えると実在したreceipt形式での回帰検出を失う。",
|
||||
"reason": "旧Ticket完了receiptの実在したcounter入力keyを新schema7→9移行fixtureへ移設した。過去のfingerprintと全attributeを不変保持し、同じoperation keyを現行content/state/closure-key判断へ再解釈できないことを検証する。旧lib.rsの削除されたfixture例外を同じ1行へ移したもので、現行writer/APIには導入しない。",
|
||||
"lines": [
|
||||
{
|
||||
"sha256": "060994ddcd0e463815b01cd11b19723fd3652bd4c741fccdb33628e97691f092",
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,26 +1,22 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
export type BrowserAppendTicketEventRequest = { author?: string | null; body: string; role: BrowserTicketThreadRole; };
|
||||
|
||||
export type BrowserCloseTicketRequest = { expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; resolution: string; };
|
||||
|
||||
export type BrowserEditTicketRequest = { author?: string | null; body?: string | null; new_string?: string | null; old_string?: string | null; replace_all?: boolean; target?: BrowserTicketTargetEdit | null; title?: string | null; };
|
||||
|
||||
export type BrowserQueueTicketRequest = { [key: string]: never; };
|
||||
|
||||
export type BrowserTicketState = "open" | "closed";
|
||||
|
||||
export type BrowserTicketTargetEdit = { action: "set"; targets: Array<TicketTarget>; } | { action: "clear"; };
|
||||
|
||||
export type BrowserTicketThreadRole = "comment" | "plan" | "decision" | "implementation_report";
|
||||
|
||||
export type BrowserTicketWorkflowState = "planning" | "ready" | "queued" | "inprogress" | "done" | "closed";
|
||||
|
||||
export type BrowserTransitionTicketStateRequest = { body?: string | null; expected_content_digest: string; expected_state: BrowserTicketWorkflowState; operation_key: string; reason: string; state: BrowserTicketWorkflowState; };
|
||||
|
||||
export type CancelTicketImplementationRequest = { assignment_id: string; operation_id: string; reason: string; };
|
||||
|
||||
export type ConflictResolution = "none" | "clean" | "conflicts_resolved";
|
||||
@@ -29,6 +25,8 @@ export type Diagnostic = { code: string; message: string; severity: DiagnosticSe
|
||||
|
||||
export type DiagnosticSeverity = "info" | "warning" | "error";
|
||||
|
||||
export type ExtensibleTicketStatus = "Open" | "Closed" | { Other: string; };
|
||||
|
||||
export type FindingSeverity = "blocker" | "major" | "minor" | "note";
|
||||
|
||||
export type InvalidProjectRecord = { label: string; reason: string; };
|
||||
@@ -57,7 +55,7 @@ export type MergeRequestWorkerIdentity = { runtime_id: string; worker_id: string
|
||||
|
||||
export type MergeStrategy = "fast_forward" | "merge";
|
||||
|
||||
export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketWorkflowState | null; };
|
||||
export type NewTicket = { assignee?: string | null; author?: string | null; body: MarkdownText; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; risk_flags: Array<string>; slug?: string | null; targets?: Array<TicketTarget>; title: string; workflow_state?: TicketState | null; };
|
||||
|
||||
export type ObjectiveDetail = { body: string; body_truncated: boolean; created_at?: string | null; event_page: QueryPage; events: Array<ObjectiveEventDetail>; id: string; linked_ticket_summaries: Array<ObjectiveLinkedTicketSummary>; linked_tickets: Array<string>; record_source: string; resource_key: string; resources: Array<ObjectiveResourceSummary>; state: string; title: string; updated_at?: string | null; };
|
||||
|
||||
@@ -91,7 +89,17 @@ export type TicketAssignmentRole = "orchestrator" | "worker" | "owner" | "contri
|
||||
|
||||
export type TicketAssignmentSummary = { assignment_id: string; runtime_id: string; worker_id: string; worker_resource_key?: string | null; };
|
||||
|
||||
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; content_digest: string; created_at?: string | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: string; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
||||
export type TicketCaseAction = { action: "close"; reason: TicketClosureReason; } | { action: "reopen"; } | { action: "correct_closure"; reason: TicketClosureReason; };
|
||||
|
||||
export type TicketCaseHistoryEntry = { action: TicketCaseAction; at: string; author: string; explanation: string; operation_key: string; previous_closure_operation_key?: string | null; references: Array<TicketReference>; };
|
||||
|
||||
export type TicketCaseUpdateRequest = { action: TicketCaseAction; expected_closure_operation_key: string | null; expected_content_digest: string; expected_state: TicketState; explanation: string; operation_key: string; references?: Array<TicketReference>; };
|
||||
|
||||
export type TicketClosureJudgment = { at: string; author: string; explanation: string; operation_key: string; reason: TicketClosureReason; references: Array<TicketReference>; };
|
||||
|
||||
export type TicketClosureReason = "completed" | "not_planned" | "duplicate" | "unknown" | { other: string; };
|
||||
|
||||
export type TicketDetail = { action_eligibility: TicketActionEligibility; artifact_count: number; artifacts: Array<string>; assignment_diagnostics: Array<string>; assignments: Array<TicketRoleAssignmentSummary>; body: string; body_truncated: boolean; closure_history: Array<TicketCaseHistoryEntry>; closure_operation_key?: string | null; content_digest: string; created_at?: string | null; current_closure?: TicketClosureJudgment | null; current_worker?: TicketAssignmentSummary | null; event_count: number; event_page: QueryPage; events: Array<TicketEventDetail>; evidence: TicketEvidenceSummary; id: string; implementation_reports: Array<TicketEvidenceEvent>; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; linked_objectives: Array<ObjectiveLinkSummary>; merge_request?: TicketMergeRequestSummary | null; merge_requests: Array<TicketMergeRequestSummary>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; record_source: string; relations: TicketDetailRelationView; resolution?: string | null; resource_key: string; risk_flags: Array<string>; state: BrowserTicketState; targets: Array<TicketTarget>; title: string; updated_at?: string | null; };
|
||||
|
||||
export type TicketDetailDerivedRelation = { at: string; author: string; forward_kind: string; inverse_kind: string; note?: string | null; source_resource_key?: string | null; source_ticket: string; };
|
||||
|
||||
@@ -111,7 +119,9 @@ export type TicketEvidenceSummary = { approved_current_subject: boolean; complet
|
||||
|
||||
export type TicketIntakeSummary = { author?: string | null; body: MarkdownText; references: Array<TicketReference>; };
|
||||
|
||||
export type TicketListItemSummary = { id: string; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: string; title: string; updated_at?: string | null; workspace_action_priority: string; };
|
||||
export type TicketLegacyWorkflowSnapshot = { queued_at?: string | null; queued_by?: string | null; resolution?: string | null; status: string; workflow_state: string; workflow_state_explicit: boolean; };
|
||||
|
||||
export type TicketListItemSummary = { closure_operation_key?: string | null; current_closure?: TicketClosureJudgment | null; id: string; legacy_handoff?: TicketLegacyWorkflowSnapshot | null; priority: string; queued_at?: string | null; queued_by?: string | null; record_source: string; resource_key: string; state: BrowserTicketState; title: string; updated_at?: string | null; workspace_action_priority: string; };
|
||||
|
||||
export type TicketListResponse = { invalid_records: Array<InvalidProjectRecord>; items: Array<TicketListItemSummary>; limit: number & { readonly "__yoiIntegerRange": "0..1000"; }; page: QueryPage; record_authority: string; workspace_id: string; };
|
||||
|
||||
@@ -133,12 +143,14 @@ export type TicketRoleAssignmentSummary = { assigned_at: string; assigned_by: st
|
||||
|
||||
export type TicketSourceRefObservation = { status: "observed"; } | { code: string; status: "unavailable"; } | { status: "not_required"; };
|
||||
|
||||
export type TicketState = "open" | "closed";
|
||||
|
||||
export type TicketStatus = "Open" | "Closed";
|
||||
|
||||
export type TicketSummary = { id: string; kind: string; labels: Array<string>; priority: string; queued_at?: string | null; queued_by?: string | null; readiness?: string | null; resource_key?: string | null; slug: string; status: ExtensibleTicketStatus; title: string; updated_at?: string | null; workflow_state: TicketState; workflow_state_explicit: boolean; };
|
||||
|
||||
export type TicketTarget = { access: TicketTargetAccess; ref_selector?: string | null; repository_key: string; };
|
||||
|
||||
export type TicketTargetAccess = "read_only" | "read_write";
|
||||
|
||||
export type TicketWorkerAttachmentBinding = { alias: string; connection_id: string; working_directory_id: string; };
|
||||
|
||||
export type TicketWorkflowState = "planning" | "ready" | "queued" | "in_progress" | "done" | "closed";
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:fc25104c077ab8c5c43f77611f505171585d2d40fd54c6538774ac17aae6cce3)
|
||||
// Input contract: 0.1.0 (sha256:6f5ab2a613fca9996680d3a08962dc580563457811e68679574ff4325c86ec4e)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -48,7 +48,7 @@ Deno.test("Ticket Browser parser accepts the generated Ticket list shape", () =>
|
||||
id: "ticket-id",
|
||||
resource_key: "T-1",
|
||||
title: "Ticket",
|
||||
state: "ready",
|
||||
state: "open",
|
||||
priority: "P2",
|
||||
updated_at: null,
|
||||
queued_by: null,
|
||||
@@ -105,16 +105,23 @@ Deno.test("Ticket Browser accepts generic Worker eligibility and assignments", (
|
||||
assigned_at: "2026-10-06T00:00:00Z",
|
||||
assigned_by: "user-1",
|
||||
assignment_id: "assignment-1",
|
||||
principal: { kind: "worker", runtime_id: "runtime-1", worker_id: "worker-1" },
|
||||
principal: {
|
||||
kind: "worker",
|
||||
runtime_id: "runtime-1",
|
||||
worker_id: "worker-1",
|
||||
},
|
||||
role: "worker",
|
||||
ticket_id: "ticket-id",
|
||||
workspace_id: "workspace-a",
|
||||
};
|
||||
assertEquals(parseTicketRoleAssignmentMutationResponse({
|
||||
assertEquals(
|
||||
parseTicketRoleAssignmentMutationResponse({
|
||||
assignment,
|
||||
ticket_id: "ticket-id",
|
||||
workspace_id: "workspace-a",
|
||||
}).assignment,
|
||||
assignment,
|
||||
ticket_id: "ticket-id",
|
||||
workspace_id: "workspace-a",
|
||||
}).assignment, assignment);
|
||||
);
|
||||
});
|
||||
|
||||
function ticketDetailFixture() {
|
||||
@@ -131,6 +138,10 @@ function ticketDetailFixture() {
|
||||
artifacts: [],
|
||||
assignment_diagnostics: [],
|
||||
assignments: [],
|
||||
closure_history: [],
|
||||
closure_operation_key: null,
|
||||
current_closure: null,
|
||||
legacy_handoff: null,
|
||||
body: "Body",
|
||||
body_truncated: false,
|
||||
current_worker: null,
|
||||
@@ -164,7 +175,7 @@ function ticketDetailFixture() {
|
||||
resolution: null,
|
||||
resource_key: "T-1",
|
||||
risk_flags: [],
|
||||
state: "planning",
|
||||
state: "open",
|
||||
targets: [
|
||||
{
|
||||
repository_key: "docs",
|
||||
@@ -342,11 +353,11 @@ Deno.test("Merge Request summaries require a nullable bounded integration eviden
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("done Ticket state is preserved when current requirement evidence is incomplete", () => {
|
||||
Deno.test("closed Ticket state is preserved when current requirement evidence is incomplete", () => {
|
||||
const fixture = ticketDetailFixture();
|
||||
const parsed = parseTicketDetail({
|
||||
...fixture,
|
||||
state: "done",
|
||||
state: "closed",
|
||||
evidence: { ...fixture.evidence, missing: ["review_after_rescope"] },
|
||||
merge_requests: [{
|
||||
...mergeRequestSummary,
|
||||
@@ -354,7 +365,7 @@ Deno.test("done Ticket state is preserved when current requirement evidence is i
|
||||
source_ref_observation: { status: "not_required" },
|
||||
}],
|
||||
});
|
||||
assertEquals(parsed.state, "done");
|
||||
assertEquals(parsed.state, "closed");
|
||||
assertEquals(parsed.evidence.complete_for_integration, false);
|
||||
assertEquals(parsed.merge_requests[0]?.review_status, "approved");
|
||||
});
|
||||
@@ -440,3 +451,135 @@ Deno.test("Ticket Browser parser validates the canonical Merge Request thread",
|
||||
"unknown field unexpected",
|
||||
);
|
||||
});
|
||||
|
||||
const closure = {
|
||||
operation_key: "close-1",
|
||||
reason: "not_planned",
|
||||
explanation: "Not planned for this scope.",
|
||||
references: [{ kind: "document", target: "result.md" }],
|
||||
author: "user:alice",
|
||||
at: "2026-10-10T00:00:00Z",
|
||||
};
|
||||
const closureHistory = [
|
||||
{
|
||||
...closure,
|
||||
reason: undefined,
|
||||
previous_closure_operation_key: null,
|
||||
action: { action: "close", reason: "not_planned" },
|
||||
},
|
||||
{
|
||||
...closure,
|
||||
reason: undefined,
|
||||
operation_key: "correct-1",
|
||||
previous_closure_operation_key: "close-1",
|
||||
action: { action: "correct_closure", reason: { other: "Deferred" } },
|
||||
},
|
||||
{
|
||||
...closure,
|
||||
reason: undefined,
|
||||
operation_key: "reopen-1",
|
||||
previous_closure_operation_key: "correct-1",
|
||||
action: { action: "reopen" },
|
||||
},
|
||||
].map(({ reason: _, ...entry }) => ({
|
||||
operation_key: entry.operation_key,
|
||||
previous_closure_operation_key: entry.previous_closure_operation_key,
|
||||
action: entry.action,
|
||||
explanation: entry.explanation,
|
||||
references: entry.references,
|
||||
author: entry.author,
|
||||
at: entry.at,
|
||||
}));
|
||||
const legacyHandoff = {
|
||||
workflow_state: "done",
|
||||
workflow_state_explicit: true,
|
||||
status: "Closed",
|
||||
queued_by: "workspace-web",
|
||||
queued_at: "2026-10-09T00:00:00Z",
|
||||
resolution: "Historical result, not a current case state",
|
||||
};
|
||||
|
||||
Deno.test("case projections retain reasons, actors, references and migration history after reopening", () => {
|
||||
const fixture = {
|
||||
...ticketDetailFixture(),
|
||||
state: "open",
|
||||
closure_operation_key: "correct-1",
|
||||
current_closure: null,
|
||||
closure_history: closureHistory,
|
||||
legacy_handoff: legacyHandoff,
|
||||
};
|
||||
const parsed = parseTicketDetail(fixture);
|
||||
assertEquals(parsed.state, "open");
|
||||
assertEquals(parsed.closure_operation_key, "correct-1");
|
||||
assertEquals(parsed.closure_history, closureHistory);
|
||||
assertEquals(parsed.legacy_handoff, legacyHandoff);
|
||||
assertEquals(
|
||||
parseTicketDetail({ ...fixture, state: "closed", current_closure: closure })
|
||||
.current_closure,
|
||||
closure,
|
||||
);
|
||||
const list = parseTicketListResponse({
|
||||
workspace_id: "workspace-a",
|
||||
limit: 30,
|
||||
page,
|
||||
invalid_records: [],
|
||||
record_authority: "sqlite",
|
||||
items: [{
|
||||
id: "ticket-id",
|
||||
resource_key: "T-1",
|
||||
title: "Ticket",
|
||||
state: "closed",
|
||||
priority: "P2",
|
||||
record_source: "sqlite",
|
||||
workspace_action_priority: "background",
|
||||
closure_operation_key: "close-1",
|
||||
current_closure: closure,
|
||||
legacy_handoff: legacyHandoff,
|
||||
}],
|
||||
});
|
||||
assertEquals(list.items[0].current_closure, closure);
|
||||
assertEquals(list.items[0].legacy_handoff, legacyHandoff);
|
||||
});
|
||||
|
||||
Deno.test("case decoders reject legacy current states and malformed closure judgments", () => {
|
||||
const fixture = ticketDetailFixture();
|
||||
for (const state of ["planning", "ready", "queued", "inprogress", "done"]) {
|
||||
assertThrows(
|
||||
() => parseTicketDetail({ ...fixture, state }),
|
||||
"state is invalid",
|
||||
);
|
||||
}
|
||||
for (const reason of ["done", { other: 1 }, { other: "ok", extra: true }]) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseTicketDetail({
|
||||
...fixture,
|
||||
current_closure: { ...closure, reason },
|
||||
}),
|
||||
"Ticket detail.current_closure.reason",
|
||||
);
|
||||
}
|
||||
for (
|
||||
const action of ["reopen", { action: "close" }, {
|
||||
action: "reopen",
|
||||
reason: "completed",
|
||||
}, { action: "complete", reason: "completed" }]
|
||||
) {
|
||||
assertThrows(
|
||||
() =>
|
||||
parseTicketDetail({
|
||||
...fixture,
|
||||
closure_history: [{ ...closureHistory[0], action }],
|
||||
}),
|
||||
"Ticket detail.closure_history[0].action",
|
||||
);
|
||||
}
|
||||
assertThrows(
|
||||
() => parseTicketDetail({ ...fixture, closure_history: {} }),
|
||||
"closure_history must be an array",
|
||||
);
|
||||
assertThrows(
|
||||
() => parseTicketDetail({ ...fixture, closure_operation_key: 1 }),
|
||||
"closure_operation_key must be a string",
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type {
|
||||
BrowserTicketState,
|
||||
InvalidProjectRecord,
|
||||
MergeRequestDetailResponse,
|
||||
MergeRequestLinkedTicketResponse,
|
||||
@@ -20,6 +21,10 @@ import type {
|
||||
TicketActionEligibility,
|
||||
TicketAssignmentPrincipal,
|
||||
TicketAssignmentSummary,
|
||||
TicketCaseAction,
|
||||
TicketCaseHistoryEntry,
|
||||
TicketClosureJudgment,
|
||||
TicketClosureReason,
|
||||
TicketDetail,
|
||||
TicketDetailDerivedRelation,
|
||||
TicketDetailRelation,
|
||||
@@ -29,14 +34,17 @@ import type {
|
||||
TicketEventDetail,
|
||||
TicketEvidenceEvent,
|
||||
TicketEvidenceSummary,
|
||||
TicketLegacyWorkflowSnapshot,
|
||||
TicketListItemSummary,
|
||||
TicketListResponse,
|
||||
TicketMergeRequestSummary,
|
||||
TicketQueueOutcome,
|
||||
TicketRef,
|
||||
TicketReference,
|
||||
TicketRoleAssignmentMutationResponse,
|
||||
TicketRoleAssignmentRecord,
|
||||
TicketRoleAssignmentSummary,
|
||||
TicketSummary,
|
||||
TicketTarget,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
|
||||
@@ -183,12 +191,208 @@ function parseInvalidProjectRecord(
|
||||
};
|
||||
}
|
||||
|
||||
function parseTicketState(value: unknown, label: string): BrowserTicketState {
|
||||
if (value !== "open" && value !== "closed") {
|
||||
throw new Error(`${label} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function parseClosureReason(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketClosureReason {
|
||||
if (
|
||||
typeof value === "string" &&
|
||||
["completed", "not_planned", "duplicate", "unknown"].includes(value)
|
||||
) {
|
||||
return value as TicketClosureReason;
|
||||
}
|
||||
const item = object(value, label);
|
||||
exact(item, ["other"], label);
|
||||
return { other: string(item.other, `${label}.other`) };
|
||||
}
|
||||
|
||||
function parseCaseAction(value: unknown, label: string): TicketCaseAction {
|
||||
const item = object(value, label);
|
||||
if (item.action === "reopen") {
|
||||
exact(item, ["action"], label);
|
||||
return { action: "reopen" };
|
||||
}
|
||||
if (item.action !== "close" && item.action !== "correct_closure") {
|
||||
throw new Error(`${label}.action is invalid`);
|
||||
}
|
||||
exact(item, ["action", "reason"], label);
|
||||
return {
|
||||
action: item.action,
|
||||
reason: parseClosureReason(item.reason, `${label}.reason`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseReference(value: unknown, label: string): TicketReference {
|
||||
const item = object(value, label);
|
||||
exact(item, ["kind", "target"], label);
|
||||
return {
|
||||
kind: string(item.kind, `${label}.kind`),
|
||||
target: string(item.target, `${label}.target`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseClosure(value: unknown, label: string): TicketClosureJudgment {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"operation_key",
|
||||
"reason",
|
||||
"explanation",
|
||||
"references",
|
||||
"author",
|
||||
"at",
|
||||
], label);
|
||||
return {
|
||||
operation_key: string(item.operation_key, `${label}.operation_key`),
|
||||
reason: parseClosureReason(item.reason, `${label}.reason`),
|
||||
explanation: string(item.explanation, `${label}.explanation`),
|
||||
references: array(item.references, `${label}.references`, parseReference),
|
||||
author: string(item.author, `${label}.author`),
|
||||
at: string(item.at, `${label}.at`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseCaseHistory(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketCaseHistoryEntry {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"operation_key",
|
||||
"previous_closure_operation_key",
|
||||
"action",
|
||||
"explanation",
|
||||
"references",
|
||||
"author",
|
||||
"at",
|
||||
], label);
|
||||
return {
|
||||
operation_key: string(item.operation_key, `${label}.operation_key`),
|
||||
previous_closure_operation_key: optionalNullableString(
|
||||
item.previous_closure_operation_key,
|
||||
`${label}.previous_closure_operation_key`,
|
||||
),
|
||||
action: parseCaseAction(item.action, `${label}.action`),
|
||||
explanation: string(item.explanation, `${label}.explanation`),
|
||||
references: array(item.references, `${label}.references`, parseReference),
|
||||
author: string(item.author, `${label}.author`),
|
||||
at: string(item.at, `${label}.at`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseLegacyHandoff(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketLegacyWorkflowSnapshot {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"workflow_state",
|
||||
"workflow_state_explicit",
|
||||
"status",
|
||||
"queued_by",
|
||||
"queued_at",
|
||||
"resolution",
|
||||
], label);
|
||||
return {
|
||||
workflow_state: string(item.workflow_state, `${label}.workflow_state`),
|
||||
workflow_state_explicit: boolean(
|
||||
item.workflow_state_explicit,
|
||||
`${label}.workflow_state_explicit`,
|
||||
),
|
||||
status: string(item.status, `${label}.status`),
|
||||
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
|
||||
queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`),
|
||||
resolution: optionalNullableString(item.resolution, `${label}.resolution`),
|
||||
};
|
||||
}
|
||||
|
||||
function caseProjection(item: Record<string, unknown>, label: string) {
|
||||
return {
|
||||
closure_operation_key: optionalNullableString(
|
||||
item.closure_operation_key,
|
||||
`${label}.closure_operation_key`,
|
||||
),
|
||||
current_closure: item.current_closure === undefined
|
||||
? undefined
|
||||
: item.current_closure === null
|
||||
? null
|
||||
: parseClosure(item.current_closure, `${label}.current_closure`),
|
||||
legacy_handoff: item.legacy_handoff === undefined
|
||||
? undefined
|
||||
: item.legacy_handoff === null
|
||||
? null
|
||||
: parseLegacyHandoff(item.legacy_handoff, `${label}.legacy_handoff`),
|
||||
};
|
||||
}
|
||||
|
||||
export function parseTicketSummary(value: unknown): TicketSummary {
|
||||
const label = "Ticket summary";
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"id",
|
||||
"kind",
|
||||
"labels",
|
||||
"priority",
|
||||
"queued_at",
|
||||
"queued_by",
|
||||
"readiness",
|
||||
"resource_key",
|
||||
"slug",
|
||||
"status",
|
||||
"title",
|
||||
"updated_at",
|
||||
"workflow_state",
|
||||
"workflow_state_explicit",
|
||||
], label);
|
||||
let status: TicketSummary["status"];
|
||||
if (item.status === "Open" || item.status === "Closed") status = item.status;
|
||||
else {
|
||||
const other = object(item.status, `${label}.status`);
|
||||
exact(other, ["Other"], `${label}.status`);
|
||||
status = { Other: string(other.Other, `${label}.status.Other`) };
|
||||
}
|
||||
return {
|
||||
id: string(item.id, `${label}.id`),
|
||||
kind: string(item.kind, `${label}.kind`),
|
||||
labels: strings(item.labels, `${label}.labels`),
|
||||
priority: string(item.priority, `${label}.priority`),
|
||||
queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`),
|
||||
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
|
||||
readiness: optionalNullableString(item.readiness, `${label}.readiness`),
|
||||
resource_key: optionalNullableString(
|
||||
item.resource_key,
|
||||
`${label}.resource_key`,
|
||||
),
|
||||
slug: string(item.slug, `${label}.slug`),
|
||||
status,
|
||||
title: string(item.title, `${label}.title`),
|
||||
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
|
||||
workflow_state: parseTicketState(
|
||||
item.workflow_state,
|
||||
`${label}.workflow_state`,
|
||||
),
|
||||
workflow_state_explicit: boolean(
|
||||
item.workflow_state_explicit,
|
||||
`${label}.workflow_state_explicit`,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function parseTicketListItem(
|
||||
value: unknown,
|
||||
label: string,
|
||||
): TicketListItemSummary {
|
||||
const item = object(value, label);
|
||||
exact(item, [
|
||||
"closure_operation_key",
|
||||
"current_closure",
|
||||
"legacy_handoff",
|
||||
"id",
|
||||
"priority",
|
||||
"queued_at",
|
||||
@@ -201,13 +405,14 @@ function parseTicketListItem(
|
||||
"workspace_action_priority",
|
||||
], label);
|
||||
return {
|
||||
...caseProjection(item, label),
|
||||
id: string(item.id, `${label}.id`),
|
||||
priority: string(item.priority, `${label}.priority`),
|
||||
queued_at: optionalNullableString(item.queued_at, `${label}.queued_at`),
|
||||
queued_by: optionalNullableString(item.queued_by, `${label}.queued_by`),
|
||||
record_source: string(item.record_source, `${label}.record_source`),
|
||||
resource_key: string(item.resource_key, `${label}.resource_key`),
|
||||
state: string(item.state, `${label}.state`),
|
||||
state: parseTicketState(item.state, `${label}.state`),
|
||||
title: string(item.title, `${label}.title`),
|
||||
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
|
||||
workspace_action_priority: string(
|
||||
@@ -727,6 +932,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
"artifacts",
|
||||
"assignment_diagnostics",
|
||||
"assignments",
|
||||
"closure_history",
|
||||
"body",
|
||||
"body_truncated",
|
||||
"created_at",
|
||||
@@ -735,6 +941,9 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
"event_page",
|
||||
"events",
|
||||
"evidence",
|
||||
"closure_operation_key",
|
||||
"current_closure",
|
||||
"legacy_handoff",
|
||||
"id",
|
||||
"implementation_reports",
|
||||
"content_digest",
|
||||
@@ -756,6 +965,12 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
"updated_at",
|
||||
], label);
|
||||
return {
|
||||
...caseProjection(item, label),
|
||||
closure_history: array(
|
||||
item.closure_history,
|
||||
`${label}.closure_history`,
|
||||
parseCaseHistory,
|
||||
),
|
||||
action_eligibility: parseActionEligibility(
|
||||
item.action_eligibility,
|
||||
`${label}.action_eligibility`,
|
||||
@@ -820,7 +1035,7 @@ export function parseTicketDetail(value: unknown): TicketDetail {
|
||||
resolution: optionalNullableString(item.resolution, `${label}.resolution`),
|
||||
resource_key: string(item.resource_key, `${label}.resource_key`),
|
||||
risk_flags: strings(item.risk_flags, `${label}.risk_flags`),
|
||||
state: string(item.state, `${label}.state`),
|
||||
state: parseTicketState(item.state, `${label}.state`),
|
||||
targets: array(item.targets, `${label}.targets`, parseTicketTarget),
|
||||
title: string(item.title, `${label}.title`),
|
||||
updated_at: optionalNullableString(item.updated_at, `${label}.updated_at`),
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => Promise<void> | void): void;
|
||||
};
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`Expected ${JSON.stringify(expected)}, received ${
|
||||
JSON.stringify(actual)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
async function assertRejects(operation: () => Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await operation();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("409")) return;
|
||||
throw error;
|
||||
}
|
||||
throw new Error("Expected stale CAS rejection");
|
||||
}
|
||||
import type { TicketCaseAction } from "#lib/generated/ticket-api.ts";
|
||||
import { ticketCaseRequest, updateTicketCase } from "./ticket-case.ts";
|
||||
|
||||
const observed = {
|
||||
content_digest: "a".repeat(64),
|
||||
state: "open",
|
||||
closure_operation_key: "last-correction",
|
||||
} as const;
|
||||
const summary = {
|
||||
id: "ticket-1",
|
||||
resource_key: "T-1",
|
||||
slug: "case",
|
||||
title: "Case",
|
||||
kind: "task",
|
||||
labels: [],
|
||||
priority: "P2",
|
||||
status: "Closed",
|
||||
workflow_state: "closed",
|
||||
workflow_state_explicit: true,
|
||||
};
|
||||
|
||||
Deno.test("case actions carry content/state/last-closure CAS without browser author or unrelated lifecycle fields", async () => {
|
||||
const actions: TicketCaseAction[] = [
|
||||
...["completed", "not_planned", "duplicate", "unknown", {
|
||||
other: "Deferred",
|
||||
}].map((reason) => ({ action: "close", reason } as TicketCaseAction)),
|
||||
{ action: "reopen" },
|
||||
{ action: "correct_closure", reason: "unknown" },
|
||||
];
|
||||
const original = globalThis.fetch;
|
||||
try {
|
||||
for (const action of actions) {
|
||||
const request = ticketCaseRequest(
|
||||
observed,
|
||||
action,
|
||||
" Case judgment ",
|
||||
"operation-1",
|
||||
);
|
||||
assertEquals(request, {
|
||||
expected_content_digest: observed.content_digest,
|
||||
expected_state: "open",
|
||||
expected_closure_operation_key: "last-correction",
|
||||
operation_key: "operation-1",
|
||||
action,
|
||||
explanation: "Case judgment",
|
||||
references: [],
|
||||
});
|
||||
globalThis.fetch = (input, init) => {
|
||||
assertEquals(input, "/workspaces/workspace%20one/tickets/T%2F1/case");
|
||||
const options = init as { method?: string; body?: string };
|
||||
assertEquals(options.method, "POST");
|
||||
assertEquals(JSON.parse(String(options.body)), request);
|
||||
return Promise.resolve(Response.json(summary));
|
||||
};
|
||||
assertEquals(
|
||||
(await updateTicketCase("workspace one", "T/1", request))
|
||||
.workflow_state,
|
||||
"closed",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
assertEquals(
|
||||
ticketCaseRequest(
|
||||
{ ...observed, closure_operation_key: null },
|
||||
{ action: "close", reason: "unknown" },
|
||||
"Close",
|
||||
"first",
|
||||
).expected_closure_operation_key,
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("case API propagates stale CAS failure rather than reporting success", async () => {
|
||||
const original = globalThis.fetch;
|
||||
globalThis.fetch = () =>
|
||||
Promise.resolve(Response.json({ message: "stale case" }, { status: 409 }));
|
||||
try {
|
||||
await assertRejects(() =>
|
||||
updateTicketCase(
|
||||
"w",
|
||||
"T-1",
|
||||
ticketCaseRequest(
|
||||
observed,
|
||||
{ action: "reopen" },
|
||||
"Reopen",
|
||||
"operation-1",
|
||||
),
|
||||
)
|
||||
);
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,47 @@
|
||||
import type {
|
||||
TicketCaseAction,
|
||||
TicketCaseUpdateRequest,
|
||||
TicketDetail,
|
||||
TicketSummary,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
import { workspaceApiJsonWithBody } from "./http.ts";
|
||||
import {
|
||||
parseTicketSummary,
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
} from "./ticket-browser.ts";
|
||||
|
||||
export function ticketCaseRequest(
|
||||
ticket: Pick<
|
||||
TicketDetail,
|
||||
"content_digest" | "state" | "closure_operation_key"
|
||||
>,
|
||||
action: TicketCaseAction,
|
||||
explanation: string,
|
||||
operationKey: string,
|
||||
): TicketCaseUpdateRequest {
|
||||
return {
|
||||
expected_content_digest: ticket.content_digest,
|
||||
expected_state: ticket.state,
|
||||
// The last judgment key survives reopening; do not derive it from current_closure.
|
||||
expected_closure_operation_key: ticket.closure_operation_key ?? null,
|
||||
operation_key: operationKey,
|
||||
action,
|
||||
explanation: explanation.trim(),
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
|
||||
export function updateTicketCase(
|
||||
workspaceId: string,
|
||||
ticketId: string,
|
||||
request: TicketCaseUpdateRequest,
|
||||
): Promise<TicketSummary> {
|
||||
return workspaceApiJsonWithBody(
|
||||
`/workspaces/${encodeURIComponent(workspaceId)}/tickets/${
|
||||
encodeURIComponent(ticketId)
|
||||
}/case`,
|
||||
{ method: "POST", body: JSON.stringify(request) },
|
||||
parseTicketSummary,
|
||||
TICKET_BROWSER_API_MAX_RESPONSE_BYTES,
|
||||
);
|
||||
}
|
||||
@@ -404,12 +404,11 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
"Tickets list should fetch lightweight paginated summaries for each Kanban lane",
|
||||
);
|
||||
assert(
|
||||
ticketPanelModel.includes('label: "Ready + Planning"') &&
|
||||
ticketPanelModel.includes('label: "In progress + Queued"') &&
|
||||
ticketPanelModel.includes('label: "Done + Closed"') &&
|
||||
ticketPanelModel.includes('label: "Open"') &&
|
||||
ticketPanelModel.includes('label: "Closed"') &&
|
||||
ticketPanelModel.includes("TICKET_LANE_PAGE_SIZE = 30") &&
|
||||
ticketPanelModel.includes("nextTicketLaneVisibleCount"),
|
||||
"Ticket Kanban should combine related states into independent 30-item display windows",
|
||||
"Ticket Kanban should separate cases into independent 30-item display windows",
|
||||
);
|
||||
assert(
|
||||
generatedTicketApi.includes(
|
||||
@@ -428,7 +427,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
);
|
||||
assert(
|
||||
ticketDetailLoad.includes("/repositories") &&
|
||||
ticketDetailPage.includes('mutate("state", "/state"') &&
|
||||
ticketDetailPage.includes("ticketCaseRequest(ticket, action") &&
|
||||
ticketDetailPage.includes("async function queueTicket") &&
|
||||
ticketDetailPage.includes("const path = ticketPath") &&
|
||||
ticketDetailPage.includes("`${path}/queue`") &&
|
||||
@@ -436,7 +435,7 @@ Deno.test("workspace Tickets surface provides Kanban and lifecycle controls", as
|
||||
ticketDetailPage.includes("mergeRequest.selector_from") &&
|
||||
ticketDetailPage.includes("summarySourceReviewStatus(mergeRequest)") &&
|
||||
ticketDetailPage.includes("mergeRequestPagePath") &&
|
||||
ticketDetailPage.includes('mutate("close", "/close"') &&
|
||||
ticketDetailPage.includes("await updateTicketCase(") &&
|
||||
ticketDetailPage.includes("mutateAssignment") &&
|
||||
ticketDetailPage.includes("can_start_manual_worker") &&
|
||||
ticketDetailPage.includes("ticket.relations.outgoing"),
|
||||
|
||||
@@ -10,29 +10,35 @@ export const fixturePage = (returned: number, more = false) => ({
|
||||
});
|
||||
const updated = (hour: number) =>
|
||||
`2026-09-28T${String(hour).padStart(2, "0")}:00:00Z`;
|
||||
export function fixtureTicket(key: string, state: string, hour = 10) {
|
||||
export function fixtureTicket(
|
||||
key: string,
|
||||
state: "open" | "closed",
|
||||
hour = 10,
|
||||
) {
|
||||
return {
|
||||
id: `internal-${key}`,
|
||||
resource_key: key,
|
||||
title: key === "T-101"
|
||||
? "Restore queued Worker submissions"
|
||||
: `${state} ticket ${key}`,
|
||||
closure_operation_key: null,
|
||||
current_closure: null,
|
||||
legacy_handoff: null,
|
||||
state,
|
||||
priority: "normal",
|
||||
queued_at: null,
|
||||
queued_by: null,
|
||||
updated_at: updated(hour),
|
||||
record_source: "fixture",
|
||||
workspace_action_priority: state === "inprogress" || state === "queued"
|
||||
? "active_work"
|
||||
: "background",
|
||||
workspace_action_priority: state === "open" ? "active_work" : "background",
|
||||
};
|
||||
}
|
||||
export function fixtureDetail(key = "T-101") {
|
||||
const summary = fixtureTicket(key, key === "T-101" ? "inprogress" : "queued");
|
||||
const summary = fixtureTicket(key, "open");
|
||||
const { workspace_action_priority: _, ...ticket } = summary;
|
||||
return {
|
||||
...ticket,
|
||||
closure_history: [],
|
||||
content_digest: "a".repeat(64),
|
||||
created_at: updated(1),
|
||||
readiness: null,
|
||||
@@ -87,7 +93,7 @@ export function fixtureDetail(key = "T-101") {
|
||||
blockers: key === "T-101"
|
||||
? [{
|
||||
blocking_resource_key: "T-99",
|
||||
blocking_state: "ready",
|
||||
blocking_state: "open",
|
||||
blocking_ticket: "internal-T-99",
|
||||
note: null,
|
||||
reason_kind: "dependency",
|
||||
@@ -108,11 +114,13 @@ export function dashboardFixture(
|
||||
const state = search.get("states")!;
|
||||
const items = empty
|
||||
? []
|
||||
: state === "inprogress,queued"
|
||||
? [fixtureTicket("T-101", "inprogress"), fixtureTicket("T-102", "queued")]
|
||||
: state === "done"
|
||||
? [fixtureTicket("T-103", "done", 12), fixtureTicket("T-104", "done", 7)]
|
||||
: [fixtureTicket("T-105", "closed", 14)];
|
||||
: state === "open"
|
||||
? [fixtureTicket("T-101", "open"), fixtureTicket("T-102", "open")]
|
||||
: [
|
||||
fixtureTicket("T-105", "closed", 14),
|
||||
fixtureTicket("T-103", "closed", 12),
|
||||
fixtureTicket("T-104", "closed", 7),
|
||||
];
|
||||
if (long && items[0]) {
|
||||
items[0].title = "長いTicket名 — ".repeat(8) +
|
||||
"preserve-the-full-title-without-horizontal-overflow";
|
||||
|
||||
@@ -35,9 +35,7 @@ export type Dashboard = {
|
||||
};
|
||||
|
||||
const states: Record<string, string> = {
|
||||
inprogress: "In progress",
|
||||
queued: "Queued",
|
||||
done: "Done",
|
||||
open: "Open",
|
||||
closed: "Closed",
|
||||
active: "Active",
|
||||
completed: "Completed",
|
||||
@@ -113,8 +111,7 @@ export function loadDashboard(
|
||||
})}`,
|
||||
parseTicketListResponse,
|
||||
);
|
||||
const activeResult = tickets("inprogress,queued");
|
||||
const doneResult = tickets("done");
|
||||
const activeResult = tickets("open");
|
||||
const closedResult = tickets("closed");
|
||||
const objectivesResult = get(
|
||||
`/objectives?limit=${HOME_LIMIT}`,
|
||||
@@ -179,7 +176,7 @@ export function loadDashboard(
|
||||
}
|
||||
const current = detail.data;
|
||||
// A concurrent transition can remove a Ticket from this snapshot's scope.
|
||||
if (!["inprogress", "queued"].includes(current.state)) {
|
||||
if (current.state !== "open") {
|
||||
return { changed: true };
|
||||
}
|
||||
const blockers = current.relations.blockers;
|
||||
@@ -218,17 +215,14 @@ export function loadDashboard(
|
||||
: result.data.page.has_more || result.data.page.source_truncated
|
||||
? `Showing ${
|
||||
Math.min(result.data.items.length, HOME_LIMIT)
|
||||
} active tickets, with in-progress work first.`
|
||||
} open tickets.`
|
||||
: undefined,
|
||||
};
|
||||
}),
|
||||
recent: Promise.all([doneResult, closedResult, objectivesResult]).then((
|
||||
[done, closed, objectives],
|
||||
recent: Promise.all([closedResult, objectivesResult]).then((
|
||||
[closed, objectives],
|
||||
): DashboardFeed => ({
|
||||
rows: recentRows([
|
||||
...(done.data?.items ?? []).map((ticket) =>
|
||||
ticketRow(workspaceId, ticket)
|
||||
),
|
||||
...(closed.data?.items ?? []).map((ticket) =>
|
||||
ticketRow(workspaceId, ticket)
|
||||
),
|
||||
@@ -243,21 +237,17 @@ export function loadDashboard(
|
||||
})),
|
||||
]),
|
||||
errors: [
|
||||
...(!done.data ? [failure("Done tickets", done.error)] : []),
|
||||
...(!closed.data ? [failure("Closed tickets", closed.error)] : []),
|
||||
...(!objectives.data ? [failure("Objectives", objectives.error)] : []),
|
||||
...([done.data, closed.data, objectives.data].some((data) =>
|
||||
...([closed.data, objectives.data].some((data) =>
|
||||
data?.invalid_records.length
|
||||
)
|
||||
? ["Some records could not be read."]
|
||||
: []),
|
||||
],
|
||||
notice:
|
||||
[done.data, closed.data, objectives.data].some((data) =>
|
||||
data?.items.length
|
||||
)
|
||||
? "Up to 5 latest updates to done / closed tickets and Objectives."
|
||||
: undefined,
|
||||
notice: [closed.data, objectives.data].some((data) => data?.items.length)
|
||||
? "Up to 5 latest updates to closed tickets and Objectives."
|
||||
: undefined,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -86,10 +86,10 @@ Deno.test("observed live source movement requires fresh review but an unchanged
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("done Ticket can retain immutable approval while current requirement evidence is incomplete", () => {
|
||||
Deno.test("closed Ticket can retain immutable approval while current requirement evidence is incomplete", () => {
|
||||
const ticket = parseTicketDetail({
|
||||
...fixtureDetail(),
|
||||
state: "done",
|
||||
state: "closed",
|
||||
evidence: {
|
||||
...fixtureDetail().evidence,
|
||||
has_merge_request: true,
|
||||
@@ -107,7 +107,7 @@ Deno.test("done Ticket can retain immutable approval while current requirement e
|
||||
source_ref_observation: { status: "not_required" },
|
||||
}],
|
||||
});
|
||||
assertEquals(ticket.state, "done");
|
||||
assertEquals(ticket.state, "closed");
|
||||
assertEquals(ticket.evidence.complete_for_integration, false);
|
||||
assertEquals(
|
||||
currentRequirementApprovalStatus(ticket.evidence),
|
||||
@@ -128,14 +128,14 @@ Deno.test("done Ticket can retain immutable approval while current requirement e
|
||||
currentRequirementApprovalStatus(recovered.evidence),
|
||||
"approved",
|
||||
);
|
||||
assertEquals(recovered.state, "done");
|
||||
assertEquals(recovered.state, "closed");
|
||||
assertEquals(
|
||||
summarySourceReviewStatus(ticket.merge_requests[0]!),
|
||||
"Integration approval recorded for immutable merged source source-1.",
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without cancelling done", async () => {
|
||||
Deno.test("Ticket route separates current requirement evidence from immutable integration approval without changing case closure", async () => {
|
||||
const source = await Deno.readTextFile(
|
||||
new URL(
|
||||
"../../routes/w/[workspaceId]/tickets/[ticketId]/+page.svelte",
|
||||
@@ -144,15 +144,15 @@ Deno.test("Ticket route separates current requirement evidence from immutable in
|
||||
);
|
||||
for (
|
||||
const required of [
|
||||
"Current requirement evidence",
|
||||
"MR requirement evidence",
|
||||
"ticket.evidence.complete_for_integration",
|
||||
"Current requirement approval:</strong> {currentRequirementApprovalStatus(ticket.evidence)}",
|
||||
"ticket.evidence.missing",
|
||||
"summarySourceReviewStatus(mergeRequest)",
|
||||
"Immutable integration evidence",
|
||||
"Integration evidence error:",
|
||||
'{#if ticket.state === "done"}',
|
||||
"Incomplete current requirement evidence does not cancel recorded completion.",
|
||||
'{#if ticket.state === "closed"}',
|
||||
"Incomplete MR evidence does not alter the recorded case judgment.",
|
||||
]
|
||||
) {
|
||||
if (!source.includes(required)) {
|
||||
|
||||
@@ -131,7 +131,7 @@
|
||||
.ticket-kanban {
|
||||
display: grid;
|
||||
align-items: stretch;
|
||||
grid-template-columns: repeat(3, minmax(18rem, 1fr));
|
||||
grid-template-columns: repeat(2, minmax(18rem, 1fr));
|
||||
gap: var(--space-3);
|
||||
overflow-x: auto;
|
||||
padding-bottom: var(--space-3);
|
||||
@@ -168,13 +168,10 @@
|
||||
border-radius: 50%;
|
||||
background: var(--text-muted);
|
||||
}
|
||||
.ticket-lane[data-state="ready-planning"] .ticket-state-dot {
|
||||
.ticket-lane[data-state="open"] .ticket-state-dot {
|
||||
background: var(--success);
|
||||
}
|
||||
.ticket-lane[data-state="inprogress-queued"] .ticket-state-dot {
|
||||
background: var(--warning);
|
||||
}
|
||||
.ticket-lane[data-state="done-closed"] .ticket-state-dot {
|
||||
.ticket-lane[data-state="closed"] .ticket-state-dot {
|
||||
background: var(--accent);
|
||||
}
|
||||
.ticket-lane-count {
|
||||
|
||||
@@ -23,56 +23,26 @@ function assertEquals<T>(actual: T, expected: T): void {
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("ticketLanes combines workflow states and sorts by state then update time", () => {
|
||||
Deno.test("ticketLanes separates open and closed cases and orders updates deterministically", () => {
|
||||
const tickets = [
|
||||
{
|
||||
id: "planning",
|
||||
state: "planning",
|
||||
title: "Planning",
|
||||
updated_at: "2026-07-30T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "ready-old",
|
||||
state: "ready",
|
||||
title: "Ready old",
|
||||
id: "old",
|
||||
state: "open",
|
||||
title: "Open old",
|
||||
updated_at: "2026-07-29T12:00:00Z",
|
||||
},
|
||||
{
|
||||
id: "ready-new",
|
||||
state: "ready",
|
||||
title: "Ready new",
|
||||
id: "new",
|
||||
state: "open",
|
||||
title: "Open new",
|
||||
updated_at: "2026-07-30T12:00:00Z",
|
||||
},
|
||||
{ id: "queued", state: "queued", title: "Queued", updated_at: null },
|
||||
{
|
||||
id: "inprogress",
|
||||
state: "inprogress",
|
||||
title: "In progress",
|
||||
updated_at: null,
|
||||
},
|
||||
{ id: "closed", state: "closed", title: "Closed", updated_at: null },
|
||||
{ id: "done", state: "done", title: "Done", updated_at: null },
|
||||
] as TicketSummary[];
|
||||
|
||||
const lanes = ticketLanes(tickets);
|
||||
assertEquals(lanes.map((lane) => lane.id), [
|
||||
"ready-planning",
|
||||
"inprogress-queued",
|
||||
"done-closed",
|
||||
]);
|
||||
assertEquals(lanes[0].tickets.map((ticket) => ticket.id), [
|
||||
"ready-new",
|
||||
"ready-old",
|
||||
"planning",
|
||||
]);
|
||||
assertEquals(lanes[1].tickets.map((ticket) => ticket.id), [
|
||||
"inprogress",
|
||||
"queued",
|
||||
]);
|
||||
assertEquals(lanes[2].tickets.map((ticket) => ticket.id), [
|
||||
"done",
|
||||
"closed",
|
||||
]);
|
||||
assertEquals(lanes.map((lane) => lane.id), ["open", "closed"]);
|
||||
assertEquals(lanes[0].tickets.map((ticket) => ticket.id), ["new", "old"]);
|
||||
assertEquals(lanes[1].tickets.map((ticket) => ticket.id), ["closed"]);
|
||||
});
|
||||
|
||||
Deno.test("ticket lane visibility advances in bounded pages of 30", () => {
|
||||
|
||||
@@ -4,35 +4,12 @@ import type {
|
||||
TicketListItemSummary,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
|
||||
export const TICKET_STATES = [
|
||||
"planning",
|
||||
"ready",
|
||||
"queued",
|
||||
"inprogress",
|
||||
"done",
|
||||
"closed",
|
||||
] as const;
|
||||
|
||||
export const TICKET_STATES = ["open", "closed"] as const;
|
||||
export type TicketState = (typeof TICKET_STATES)[number];
|
||||
|
||||
export type WorkspaceOrchestratorStatus = BrowserWorkspaceOrchestratorResponse;
|
||||
|
||||
const LANE_DEFINITIONS = [
|
||||
{
|
||||
id: "ready-planning",
|
||||
label: "Ready + Planning",
|
||||
states: ["ready", "planning"],
|
||||
},
|
||||
{
|
||||
id: "inprogress-queued",
|
||||
label: "In progress + Queued",
|
||||
states: ["inprogress", "queued"],
|
||||
},
|
||||
{
|
||||
id: "done-closed",
|
||||
label: "Done + Closed",
|
||||
states: ["done", "closed"],
|
||||
},
|
||||
{ id: "open", label: "Open", states: ["open"] },
|
||||
{ id: "closed", label: "Closed", states: ["closed"] },
|
||||
] as const;
|
||||
|
||||
export const TICKET_LANE_PAGE_SIZE = 30;
|
||||
@@ -44,15 +21,6 @@ export type TicketCardSummary = Pick<
|
||||
"id" | "resource_key" | "title" | "state" | "priority" | "updated_at"
|
||||
>;
|
||||
|
||||
const STATE_SORT_ORDER = new Map<string, number>([
|
||||
["ready", 0],
|
||||
["planning", 1],
|
||||
["inprogress", 0],
|
||||
["queued", 1],
|
||||
["done", 0],
|
||||
["closed", 1],
|
||||
]);
|
||||
|
||||
export type TicketLane = {
|
||||
id: TicketLaneId;
|
||||
label: string;
|
||||
@@ -77,9 +45,6 @@ export function sortTickets(
|
||||
tickets: TicketCardSummary[],
|
||||
): TicketCardSummary[] {
|
||||
return [...tickets].sort((left, right) => {
|
||||
const stateDelta = (STATE_SORT_ORDER.get(left.state) ?? 99) -
|
||||
(STATE_SORT_ORDER.get(right.state) ?? 99);
|
||||
if (stateDelta !== 0) return stateDelta;
|
||||
const updatedDelta = updatedAt(right) - updatedAt(left);
|
||||
if (updatedDelta !== 0) return updatedDelta;
|
||||
return left.id.localeCompare(right.id);
|
||||
|
||||
@@ -31,8 +31,8 @@
|
||||
{#if refreshError}<p class="home-error" role="alert">{refreshError}</p>{/if}
|
||||
<div class="home-columns">
|
||||
<DashboardSection id="home-attention" title="Needs attention" empty="No open Merge Requests." result={data.dashboard.reviews} />
|
||||
<DashboardSection id="home-active" title="In progress" empty="No in-progress or queued tickets." result={data.dashboard.active} />
|
||||
<DashboardSection id="home-recent" title="Recent updates" empty="No done / closed tickets or Objectives yet." result={data.dashboard.recent} />
|
||||
<DashboardSection id="home-active" title="Open tickets" empty="No open tickets." result={data.dashboard.active} />
|
||||
<DashboardSection id="home-recent" title="Recent updates" empty="No closed tickets or Objectives yet." result={data.dashboard.recent} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -86,7 +86,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl
|
||||
);
|
||||
await Promise.all(Object.values(result!.dashboard));
|
||||
expect(depends).toHaveBeenCalledWith("workspace:home");
|
||||
expect(fetch).toHaveBeenCalledTimes(7);
|
||||
expect(fetch).toHaveBeenCalledTimes(6);
|
||||
for (const [path, init] of fetch.mock.calls) {
|
||||
const url = new URL(String(path), "https://example.test");
|
||||
expect(init?.method ?? "GET").toBe("GET");
|
||||
@@ -96,7 +96,7 @@ test("loader only uses bounded read-only supported endpoints and advertises expl
|
||||
expect(url.searchParams.get("limit")).toBe("5");
|
||||
}
|
||||
}
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).includes("states=done")))
|
||||
expect(fetch.mock.calls.some(([url]) => String(url).includes("states=open")))
|
||||
.toBe(true);
|
||||
expect(
|
||||
fetch.mock.calls.some(([url]) => String(url).includes("states=closed")),
|
||||
@@ -152,7 +152,7 @@ test("one recent source failure preserves the other records and does not assert
|
||||
expect((await screen.findByRole("alert")).textContent).toContain(
|
||||
"Objectives",
|
||||
);
|
||||
expect(screen.queryByText("No done / closed tickets or Objectives yet."))
|
||||
expect(screen.queryByText("No closed tickets or Objectives yet."))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
@@ -169,7 +169,7 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti
|
||||
const fallback = fixtureFetch();
|
||||
const fetchFn: typeof fetch = (path, init) =>
|
||||
String(path).endsWith("/tickets/T-101")
|
||||
? Promise.resolve(Response.json({ ...fixtureDetail(), state: "done" }))
|
||||
? Promise.resolve(Response.json({ ...fixtureDetail(), state: "closed" }))
|
||||
: fallback(path, init);
|
||||
const feed = await data("home-owner", fetchFn).dashboard.active;
|
||||
expect(feed.rows.map((row) => row.reference)).toEqual(["T-102"]);
|
||||
@@ -179,9 +179,9 @@ test("a concurrent Ticket transition leaves the active scope with a refresh noti
|
||||
test("empty resources have scoped empty states and no fabricated totals", async () => {
|
||||
mount(data("home-empty"));
|
||||
expect(await screen.findByText("No open Merge Requests.")).toBeTruthy();
|
||||
expect(await screen.findByText("No in-progress or queued tickets."))
|
||||
expect(await screen.findByText("No open tickets."))
|
||||
.toBeTruthy();
|
||||
expect(await screen.findByText("No done / closed tickets or Objectives yet."))
|
||||
expect(await screen.findByText("No closed tickets or Objectives yet."))
|
||||
.toBeTruthy();
|
||||
expect(screen.queryAllByRole("link")).toHaveLength(0);
|
||||
});
|
||||
|
||||
@@ -168,7 +168,7 @@
|
||||
labels: [],
|
||||
body: createBody,
|
||||
risk_flags: [],
|
||||
workflow_state: "planning",
|
||||
workflow_state: "open",
|
||||
targets: normalizedCreateTargets(),
|
||||
};
|
||||
try {
|
||||
@@ -297,7 +297,7 @@
|
||||
</p>
|
||||
{:else if !orchestrator.data?.online}
|
||||
<p class="workspace-callout">
|
||||
Orchestrator is offline. Requests can be queued; progress and results can still be recorded directly.
|
||||
Orchestrator is offline. Requests can be queued; case decisions and results can still be recorded directly.
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
|
||||
@@ -9,11 +9,7 @@ import type { TicketListResponse } from "#lib/generated/ticket-api.ts";
|
||||
import type { WorkspaceOrchestratorStatus } from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
|
||||
const LANE_STATES = {
|
||||
"ready-planning": ["ready", "planning"],
|
||||
"inprogress-queued": ["inprogress", "queued"],
|
||||
"done-closed": ["done", "closed"],
|
||||
} as const;
|
||||
const LANE_STATES = { open: ["open"], closed: ["closed"] } as const;
|
||||
|
||||
export type TicketLaneId = keyof typeof LANE_STATES;
|
||||
|
||||
@@ -48,15 +44,13 @@ async function loadLane(
|
||||
export const load: PageLoad = async ({ fetch, params }) => {
|
||||
const workspaceId = params.workspaceId;
|
||||
const [
|
||||
readyPlanning,
|
||||
inprogressQueued,
|
||||
doneClosed,
|
||||
open,
|
||||
closed,
|
||||
orchestrator,
|
||||
repositories,
|
||||
] = await Promise.all([
|
||||
loadLane(fetch, workspaceId, "ready-planning"),
|
||||
loadLane(fetch, workspaceId, "inprogress-queued"),
|
||||
loadLane(fetch, workspaceId, "done-closed"),
|
||||
loadLane(fetch, workspaceId, "open"),
|
||||
loadLane(fetch, workspaceId, "closed"),
|
||||
loadJson<WorkspaceOrchestratorStatus>(
|
||||
fetch,
|
||||
workspaceApiPath(workspaceId, "/orchestrator"),
|
||||
@@ -69,9 +63,8 @@ export const load: PageLoad = async ({ fetch, params }) => {
|
||||
return {
|
||||
workspaceId,
|
||||
ticketLanes: {
|
||||
"ready-planning": readyPlanning,
|
||||
"inprogress-queued": inprogressQueued,
|
||||
"done-closed": doneClosed,
|
||||
open,
|
||||
closed,
|
||||
},
|
||||
orchestrator,
|
||||
repositories,
|
||||
|
||||
@@ -12,16 +12,18 @@
|
||||
parseTicketQueueOutcome,
|
||||
parseTicketRoleAssignmentMutationResponse,
|
||||
} from "#lib/workspace/api/ticket-browser.ts";
|
||||
import { ticketCaseRequest, updateTicketCase } from "#lib/workspace/api/ticket-case.ts";
|
||||
import { mergeRequestPagePath } from "#lib/workspace/api/merge-requests.ts";
|
||||
import { currentRequirementApprovalStatus, summarySourceReviewStatus } from "#lib/workspace/merge-request-status.ts";
|
||||
import {
|
||||
relationLabel,
|
||||
TICKET_STATES,
|
||||
ticketWorkerLaunchHref,
|
||||
type WorkspaceOrchestratorStatus,
|
||||
} from "#lib/workspace/tickets/ticket-panel.ts";
|
||||
import type { ApiResult } from "#lib/workspace/api/http.ts";
|
||||
import type {
|
||||
TicketCaseAction,
|
||||
TicketClosureReason,
|
||||
TicketTarget,
|
||||
TicketTargetAccess,
|
||||
} from "#lib/generated/ticket-api.ts";
|
||||
@@ -37,7 +39,6 @@
|
||||
access: TicketTargetAccess;
|
||||
};
|
||||
|
||||
const MUTABLE_TICKET_STATES = TICKET_STATES;
|
||||
|
||||
function editableTargets(targets: TicketTarget[]): EditableTicketTarget[] {
|
||||
return targets.map((target) => ({
|
||||
@@ -78,12 +79,11 @@
|
||||
let targetDrafts = $state<EditableTicketTarget[]>(
|
||||
editableTargets(loadedTicket.targets),
|
||||
);
|
||||
let nextState = $state(loadedTicket.state);
|
||||
let transitionReason = $state("");
|
||||
let progressBody = $state("");
|
||||
let closureReason = $state("unknown");
|
||||
let otherClosureReason = $state("");
|
||||
let threadRole = $state("comment");
|
||||
let threadBody = $state("");
|
||||
let resolution = $state("");
|
||||
let busy = $state<string | null>(null);
|
||||
let errorMessage = $state<string | null>(null);
|
||||
let queueMessage = $state<string | null>(null);
|
||||
@@ -139,17 +139,16 @@
|
||||
editTitle = updatedTicket.title;
|
||||
editBody = updatedTicket.body;
|
||||
targetDrafts = editableTargets(updatedTicket.targets);
|
||||
nextState = updatedTicket.state;
|
||||
}
|
||||
|
||||
function resetTicketView(updatedTicket: TicketDetail): void {
|
||||
applyTicket(updatedTicket);
|
||||
editing = false;
|
||||
transitionReason = "";
|
||||
progressBody = "";
|
||||
closureReason = "unknown";
|
||||
otherClosureReason = "";
|
||||
threadRole = "comment";
|
||||
threadBody = "";
|
||||
resolution = "";
|
||||
busy = null;
|
||||
errorMessage = null;
|
||||
queueMessage = null;
|
||||
@@ -363,7 +362,7 @@
|
||||
}
|
||||
|
||||
async function markReady() {
|
||||
if (ticket.state !== "planning" || busy) return;
|
||||
if (ticket.state !== "open" || busy) return;
|
||||
readyOperationKey ??= crypto.randomUUID();
|
||||
if (
|
||||
await mutate("ready", "/ready", {
|
||||
@@ -376,18 +375,33 @@
|
||||
}
|
||||
}
|
||||
|
||||
async function transition(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (
|
||||
await mutate("state", "/state", {
|
||||
state: nextState,
|
||||
operation_key: crypto.randomUUID(),
|
||||
expected_content_digest: ticket.content_digest,
|
||||
expected_state: ticket.state,
|
||||
reason: transitionReason.trim(),
|
||||
body: progressBody.trim() || null,
|
||||
})
|
||||
) { transitionReason = ""; progressBody = ""; }
|
||||
function selectedClosureReason(): TicketClosureReason {
|
||||
return closureReason === "other" ? { other: otherClosureReason.trim() } : closureReason as TicketClosureReason;
|
||||
}
|
||||
|
||||
async function caseDecision(action: TicketCaseAction): Promise<void> {
|
||||
if (busy || !transitionReason.trim()) return;
|
||||
const generation = routeGeneration;
|
||||
const path = ticketPath;
|
||||
const request = ticketCaseRequest(ticket, action, transitionReason, crypto.randomUUID());
|
||||
busy = action.action;
|
||||
errorMessage = null;
|
||||
try {
|
||||
await updateTicketCase(data.workspaceId, data.ticketId, request);
|
||||
if (generation !== routeGeneration) return;
|
||||
const updated = await workspaceApiJson(path, parseTicketDetail, TICKET_BROWSER_API_MAX_RESPONSE_BYTES);
|
||||
if (generation !== routeGeneration) return;
|
||||
applyTicket(updated);
|
||||
transitionReason = "";
|
||||
} catch (error) {
|
||||
if (generation === routeGeneration) errorMessage = ticketMutationError(error);
|
||||
} finally {
|
||||
if (generation === routeGeneration) busy = null;
|
||||
}
|
||||
}
|
||||
|
||||
function closureReasonLabel(reason: TicketClosureReason): string {
|
||||
return typeof reason === "string" ? relationLabel(reason) : reason.other;
|
||||
}
|
||||
|
||||
async function appendThread(event: SubmitEvent) {
|
||||
@@ -401,19 +415,6 @@
|
||||
) threadBody = "";
|
||||
}
|
||||
|
||||
async function closeTicket(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (!resolution.trim()) return;
|
||||
if (
|
||||
await mutate("close", "/close", {
|
||||
resolution: resolution.trim(),
|
||||
operation_key: crypto.randomUUID(),
|
||||
expected_content_digest: ticket.content_digest,
|
||||
expected_state: ticket.state,
|
||||
})
|
||||
) resolution = "";
|
||||
}
|
||||
|
||||
function eventTitle(kind: string): string {
|
||||
return relationLabel(kind);
|
||||
}
|
||||
@@ -550,22 +551,29 @@
|
||||
|
||||
<aside class="ticket-control-rail">
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Progress decision</h2></header>
|
||||
<p class="workspace-empty-copy">State records progress only. It does not start or stop a Worker, grant access, approve review, or merge code. Reopening does not resume an old Worker.</p>
|
||||
<form class="ticket-control-form" onsubmit={transition}>
|
||||
<label>State
|
||||
<select bind:value={nextState}>
|
||||
{#each MUTABLE_TICKET_STATES as state}<option value={state}>{state}</option>{/each}
|
||||
<header><h2>Case decision</h2></header>
|
||||
<p class="workspace-empty-copy">Open means the case remains under consideration; closed does not necessarily mean completed. Case decisions do not start or stop Workers, grant access, approve review, merge code, or change tags. Reopening does not resume an old Worker.</p>
|
||||
<form class="ticket-control-form" onsubmit={(event) => { event.preventDefault(); void caseDecision(ticket.state === "open" ? { action: "close", reason: selectedClosureReason() } : { action: "reopen" }); }}>
|
||||
<label>Explanation<textarea bind:value={transitionReason} rows="3" required></textarea></label>
|
||||
<label>Closure reason
|
||||
<select bind:value={closureReason}>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="not_planned">Not planned</option>
|
||||
<option value="duplicate">Duplicate</option>
|
||||
<option value="unknown">Unknown</option>
|
||||
<option value="other">Other</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>Reason<input bind:value={transitionReason} placeholder="Completion, start, or reopening decision" required /></label>
|
||||
<label>Result, references, and remaining work (optional)<textarea bind:value={progressBody} rows="3" placeholder="A comment can be the result. Add links and remaining questions here."></textarea></label>
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || nextState === ticket.state || !transitionReason.trim()}>
|
||||
{nextState === "done" ? "Complete Ticket" : ticket.state === "done" || ticket.state === "closed" ? "Reopen / apply state" : "Apply state"}
|
||||
{#if closureReason === "other"}<label>Other reason<input bind:value={otherClosureReason} required={ticket.state === "open"} /></label>{/if}
|
||||
<button class="workspace-secondary-button" type="submit" disabled={busy !== null || !transitionReason.trim() || (ticket.state === "open" && closureReason === "other" && !otherClosureReason.trim())}>
|
||||
{ticket.state === "open" ? "Close ticket" : "Reopen ticket"}
|
||||
</button>
|
||||
{#if ticket.state === "closed" && ticket.current_closure}
|
||||
<button class="workspace-secondary-button" type="button" disabled={busy !== null || !transitionReason.trim() || (closureReason === "other" && !otherClosureReason.trim())} onclick={() => void caseDecision({ action: "correct_closure", reason: selectedClosureReason() })}>Correct closure</button>
|
||||
{/if}
|
||||
</form>
|
||||
{#if ticket.state === "planning"}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Marking ready…" : "Mark ready"}</button>
|
||||
{#if ticket.state === "open"}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null} onclick={markReady}>{busy === "ready" ? "Recording readiness…" : "Record routing readiness"}</button>
|
||||
{/if}
|
||||
<button class="workspace-secondary-button ticket-queue-button" type="button" disabled={busy !== null || !ticket.action_eligibility.can_queue} onclick={() => void queueTicket()}>{busy === "queue" ? "Queueing…" : "Request Orchestrator (queue)"}</button>
|
||||
<p class="workspace-empty-copy">Queue requests only this Ticket. Dependencies remain diagnostic information and are not automatically queued.</p>
|
||||
@@ -631,12 +639,12 @@
|
||||
</button>
|
||||
</form></details>
|
||||
{/if}
|
||||
{#if ticket.state === "inprogress" && workerAssignment}
|
||||
{#if workerAssignment}
|
||||
<details class="ticket-cancel-implementation">
|
||||
<summary>Cancel implementation</summary>
|
||||
<form class="ticket-control-form" onsubmit={cancelImplementation}>
|
||||
<p class="workspace-empty-copy">
|
||||
Cancel the assigned Worker’s unfinished work and return this Ticket to ready. Responsibility is retained. This does not delete the Worker or Workdir.
|
||||
Cancel the assigned Worker’s unfinished work without changing the case state. Responsibility is retained. This does not delete the Worker or Workdir.
|
||||
</p>
|
||||
<label>Reason<textarea bind:value={cancellationReason} rows="3" required></textarea></label>
|
||||
<button
|
||||
@@ -644,7 +652,7 @@
|
||||
type="submit"
|
||||
disabled={busy !== null || !cancellationReason.trim()}
|
||||
>
|
||||
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel and return to ready"}
|
||||
{busy === "cancel-implementation" ? "Cancelling…" : "Cancel unfinished work"}
|
||||
</button>
|
||||
</form>
|
||||
</details>
|
||||
@@ -724,16 +732,16 @@
|
||||
{#each ticket.evidence.missing as missing}<li>{missing}</li>{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from Ticket progress and persisted integration approval.</p>
|
||||
{#if ticket.state === "done"}
|
||||
<p class="workspace-empty-copy">This Ticket remains done. Incomplete current requirement evidence does not cancel recorded completion.</p>
|
||||
<p class="workspace-empty-copy">This evaluates linked MR requirements, separately from the Ticket case judgment and persisted integration approval.</p>
|
||||
{#if ticket.state === "closed"}
|
||||
<p class="workspace-empty-copy">This case remains closed. Incomplete MR evidence does not alter the recorded case judgment.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{/if}
|
||||
<section class="ticket-control-card">
|
||||
<header><h2>Merge Requests</h2></header>
|
||||
<p class="workspace-empty-copy">Ticket completion is not MR approval or integration.</p>
|
||||
<p class="workspace-empty-copy">Ticket closure is not MR approval or integration.</p>
|
||||
{#if mergeRequests.length > 0}
|
||||
{#each mergeRequests as mergeRequest (mergeRequest.merge_request_id)}
|
||||
<article class="ticket-control-card">
|
||||
@@ -759,20 +767,33 @@
|
||||
</article>
|
||||
{/each}
|
||||
{:else}
|
||||
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be completed with a comment or document result.</p>
|
||||
<p class="workspace-empty-copy">No Merge Requests linked. A Ticket can be closed with a comment or document result.</p>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if ticket.state !== "closed"}
|
||||
<details class="ticket-control-card ticket-close-card">
|
||||
<summary>Close ticket</summary>
|
||||
<form class="ticket-control-form" onsubmit={closeTicket}>
|
||||
<label>Resolution<textarea bind:value={resolution} rows="5" required></textarea></label>
|
||||
<button class="workspace-danger-button" type="submit" disabled={busy !== null || !resolution.trim()}>Close ticket</button>
|
||||
</form>
|
||||
{#if ticket.current_closure}
|
||||
<section class="ticket-control-card"><header><h2>Current closure</h2></header>
|
||||
<p>{closureReasonLabel(ticket.current_closure.reason)} · {ticket.current_closure.author} · {prettyDate(ticket.current_closure.at)}</p>
|
||||
<RichMarkdown text={ticket.current_closure.explanation} />
|
||||
{#each ticket.current_closure.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
|
||||
</section>
|
||||
{/if}
|
||||
{#if ticket.closure_history.length > 0}
|
||||
<details class="ticket-control-card"><summary>Case history ({ticket.closure_history.length})</summary>
|
||||
{#each ticket.closure_history as judgment (judgment.operation_key)}
|
||||
<article>
|
||||
<p>{relationLabel(judgment.action.action)}{judgment.action.action !== "reopen" ? `: ${closureReasonLabel(judgment.action.reason)}` : ""} · {judgment.author} · {prettyDate(judgment.at)}</p>
|
||||
<RichMarkdown text={judgment.explanation} />
|
||||
{#each judgment.references as reference}<p>{reference.kind}: {reference.target}</p>{/each}
|
||||
</article>
|
||||
{/each}
|
||||
</details>
|
||||
{/if}
|
||||
{#if ticket.legacy_handoff}
|
||||
<details class="ticket-control-card"><summary>Legacy routing handoff</summary>
|
||||
<p>Historical state: {ticket.legacy_handoff.workflow_state}</p>
|
||||
{#if ticket.legacy_handoff.resolution}<RichMarkdown text={ticket.legacy_handoff.resolution} />{/if}
|
||||
</details>
|
||||
{:else if ticket.resolution}
|
||||
<section class="ticket-control-card"><header><h2>Resolution</h2></header><RichMarkdown text={ticket.resolution} /></section>
|
||||
{/if}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
@@ -30,6 +30,7 @@ Deno.test("ticket detail fences stale mutation responses", () => {
|
||||
const operation of [
|
||||
"async function mutate(",
|
||||
"async function queueTicket(",
|
||||
"async function caseDecision(",
|
||||
"async function mutateAssignment(",
|
||||
]
|
||||
) {
|
||||
|
||||
@@ -40,6 +40,11 @@ export default defineConfig({
|
||||
},
|
||||
|
||||
proxy: {
|
||||
// Case judgments use the canonical authenticated Workspace API route.
|
||||
"/workspaces": {
|
||||
target: "http://127.0.0.1:8787",
|
||||
changeOrigin: true,
|
||||
},
|
||||
"/api": {
|
||||
target: "http://127.0.0.1:8787",
|
||||
changeOrigin: true,
|
||||
|
||||
Reference in New Issue
Block a user